Old-School Front-End · Frameworks / 2026 ・ No.07
いにしえのフロントエンド入門 フレームワーク編
第7回 副作用とデータ取得 ── useEffect、use と Suspense、本物の Spring Boot
公開
第6回までは、レートは 151.23 の決め打ちで、送り先は練習用サーバーでした。第7回は バックエンド編で作った Spring Boot につなぎ、レートを日付ごとに useEffect で取り、ログインしている人を use と Suspense で読みます。
そのうえで、開発用では問い合わせが2回飛ぶ、返事の順番が入れかわる、CORS で止まる、ログインで合言葉が変わる、ログインが切れる、の5つを実測します。
決め打ちをやめる
新井レートを取るなら、App の中で fetch して setRate すればいいんですよね。
古井部品の関数の中でそれを書くと、描くたびに問い合わせて、setRate でまた描く。終わらないぞ。
全15回の路線図(いまここ:第7回)
第5回で見たとおり、部品の関数は「描くための計算」で、何度でも呼ばれます。問い合わせのように外の世界に何かをすること(副作用)は、描くことと分けて、useEffect に書きます。今回つなぐのは、バックエンド編 第15回の Spring Boot(4.1.2、Spring Security 7.1.2)です。ログインはセッションのクッキー、書き込みには合言葉(CSRF トークン)が要る、という第11回の決まりのままです。
サーバーに窓口を2つ足す
バックエンド編のアプリには、画面が欲しい窓口が2つありませんでした。その日のレートと、明細つきの申請を1回で受け取る窓口です。fw7 という小さなパッケージで足しました。
/** その日の 1ドル何円か。ms は練習用:わざと遅く返す(順番が入れかわる実験のため) */
@GetMapping("/api/v11/rates")
public Map<String, Object> rate(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam(defaultValue = "0") long ms) throws InterruptedException {
BigDecimal rate = Rates.of(date);
if (rate == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no rate for " + date);
Thread.sleep(Math.min(ms, 5000));
return Map.of("date", date.toString(), "base", "USD", "quote", "JPY", "rate", rate);
}
/** 明細つきの申請を1回で受け取る:申請の中身は JSON(claim)、領収書はファイル(receipt、0個以上) */
@PostMapping("/api/v11/claims")
public ResponseEntity<Map<String, Object>> create(@Valid @RequestPart("claim") ClaimRequest claim,
@RequestPart(name = "receipt", required = false) List<MultipartFile> receipts,
@AuthenticationPrincipal LoginUser u) throws Exception {
ScreenClaimService.Created c = claims.create(claim, receipts == null ? List.of() : receipts, u);
return ResponseEntity.created(URI.create("/api/v11/claims/" + c.id()))
.body(Map.of("id", c.id(), "total", c.total().setScale(0, RoundingMode.DOWN)));
}
レートは練習用の値です(2026-10-15 を 151.23 として、日ごとに少し動かした)。申請は、中身を JSON、領収書をファイルのまま、1回の multipart で受け取ります。決まりは第6回の validate.js と同じものを Bean Validation の印で書き、項目をまたぐ決まり(3万円以上は領収書、5万円を超えたら理由)は担当者のクラスで確かめます。
useEffect でレートを取る
// ドルの行の日付(並べて1つの文字に)。これが変わったときだけ、レートを問い合わせる
const usdDates = [...new Set(rows.filter((r) => r.currency === 'USD' && r.date !== '').map((r) => r.date))].sort().join(',');
useEffect(() => {
if (usdDates === '') return;
const ctrl = new AbortController();
for (const date of usdDates.split(',')) {
getRate(date, ctrl.signal)
.then((rate) => setRates((prev) => ({ ...prev, [date]: rate }))) // 日付ごとに置くので、返事の順番が入れかわっても困らない
.catch((e) => {
if (e instanceof SignedOut) recheck();
// AbortError は、後片付けで止めた問い合わせ。何もしない
});
}
return () => ctrl.abort(); // 後片付け:日付が変わった・画面を離れたら、途中の問い合わせは止める
}, [usdDates, logins]);
レートは「その行の日付の」レートにしました。ドルの行の日付を並べた文字 usdDates を依存の配列に入れると、React は描いたあとで前回と比べ、変わったときだけ effect を動かします。返事は日付ごとに rates に置くので、どの順番で届いても、その日付の場所に入るだけです。
effect が動く順番
# ログインしたあとで申請フォームを開いた(ドルの行は 10/15 の1行)
開発用(StrictMode) 本番用
/api/v11/rates を送った2 1
そのうち止めた(abort)1 0
/api/v11/me を送った1 1
名前が出るまで537 ms 236 ms
レートが出るまで556 ms 255 ms
# 時間は Playwright で開いてから。開発用は Vite の変換も入る
effect が返す関数は後片付けです。日付が変わったとき(次の effect の前)と、部品が画面から外れるときに呼ばれます。ここでは AbortController で、途中の問い合わせを止めています。
開発用では2回飛ぶ
三の表で、開発用だけレートの問い合わせが2回飛び、1回は止められていました。小さな部品で、後片付けの書き方を変えて比べます。
# lab.html?lab=effect(StrictMode の中、開発用)。2026-10-15 のレートを1回取るだけの部品 書き方 送った setRate 起きた順 [] ・後片付けなし2 2 effect → cleanup → effect → apply → apply [] ・abort で後片付け2 1 effect → cleanup → effect → AbortError → apply 配列なし(3秒)289 288 effect → cleanup → effect → apply → cleanup … # StrictMode は開発用だけ、effect を「付ける → 外す → 付ける」と2回動かす。本番用では1回 # 配列を書き忘れると、描くたびに問い合わせ、setRate でまた描く。3秒で 289 回
<StrictMode> は開発用だけ、部品を一度外して付けなおし、effect を2回動かします。後片付けがないと、2つの返事が両方とも setRate を呼びました。今回はどちらも同じ値なので害はありませんが、「申請を1件作る」effect なら2件できます。後片付けで1回目を止めれば、本番用と同じ結果になります。StrictMode の2回は、後片付けの書き忘れを見つけるための仕掛けです。
依存の配列を書き忘れると、描くたびに問い合わせ、setRate でまた描くので、3秒で289回問い合わせました。逆に、使っている値を配列から抜くと、lint が教えてくれます。
# App.jsx の依存の配列から usdDates を消して npx oxlint src/App.jsx:59:9: warning react-hooks(exhaustive-deps): React Hook useEffect has a missing dependency: 'usdDates' help: Either include it or remove the dependency array.
返事の順番が入れかわる
問い合わせは、送った順に返ってくるとはかぎりません。日付を1つの状態にして、遅い日付と速い日付を続けて選んでみます。
# lab.html?lab=race。10/14(1.2秒かかる)を選び、0.15秒後に 10/15(0.1秒)を選んだ 書き方 0.4秒後 2秒後 止めた問い合わせ ① 後片付けなし151.23(15日)150.86(14日) ずれた- ② ignore フラグ151.23(15日)151.23(15日)- ③ AbortController151.23(15日)151.23(15日)13, 14 # どれも、選んであるのは 10/15。①は遅れて届いた 10/14 の返事が、あとから上書きした
①は、10/15 のレートがいったん正しく出たあとで、遅れて届いた 10/14 の返事が上書きしました。画面は「10/15」と言いながら、10/14 のレートで計算しています。②は後片付けで「もう要らない」と印を付け、遅れた返事を捨てます。③は問い合わせそのものを止めるので、サーバーの仕事もネットワークも減ります。申請フォームでは③に加えて、日付ごとに置くことで、順番に左右されない形にしました。
use と Suspense で待つ
// ログインしている人を、1回だけ問い合わせて覚えておく(use で読む Promise は、描くたびに作りなおさない)
import { getMe } from './api.js';
let mePromise = null;
export const meOnce = () => (mePromise ??= getMe());
export const forgetMe = () => { mePromise = null; }; // ログインしなおしたら、次の meOnce() で問い合わせなおす
// App.jsx
<Suspense fallback={<p>{T.meLoading}</p>}>
<Account key={checks} me={meOnce()} onLogin={onLogin} />
</Suspense>
// components/Account.jsx
/** だれがログインしているか。me は Promise で、use が中身を待つ(待つあいだは Suspense の fallback) */
export default function Account({ me, onLogin }) {
const user = use(me);
if (user) return <p className="small text-body-secondary mb-3" id="who">{T.hello(user.displayName)}</p>;
return <LoginCard onLogin={onLogin} />;
}
だれがログインしているかは、別の書き方にしました。use(me) は Promise の中身を待ち、待っているあいだは部品を描かずに、いちばん近い <Suspense> の fallback(「ログインを確かめています…」)を出します。useEffect のように「まず空で描いて、あとで埋める」書き方がいらず、待つ範囲を Suspense で決められます。
# lab.html?lab=uncached(3秒間) Promise を作る場所 /api/v11/me 画面 部品の中で毎回 getMe()317 「待っています…」のまま 外で1回(meOnce)1 新井 # use が待っているあいだ部品は描かれず、描きなおすたびに新しい Promise を作って、また待つ。警告は出なかった
ただし、Promise は部品の外で1回だけ作ります。部品の中で use(getMe()) と書くと、待つたびに描きなおしが起き、そのたびに新しい Promise を作って、また待ちます。3秒で317回問い合わせ、名前は最後まで出ませんでした。警告も出ません。ここでは meOnce() で1つを覚えておき、ログインしなおしたときだけ忘れます。
CORS:止めるのはブラウザ
送り先は2通り
server: {
// /api で始まる呼び出しを、Spring Boot(8080)へ取り次ぐ(ブラウザから見ると同じ所。第6回は練習用サーバーへ)
proxy: { '/api': process.env.KEIHI_API ?? 'http://localhost:8080' },
},
第6回と同じく、開発中は Vite に /api を取り次がせます(送り先を 8080 に変えただけ)。では、ページ(5173)から 8080 へ直接送るとどうなるか。
# lab.html?lab=cors。ページは 5173、送り先だけを変えた(arai でログインしてレートを取る) 手順 8080 へ直接 8080 へ直接+CORS 設定 /api(Vite が取り次ぐ) GET /api/csrfTypeError 200 200 POST /api/loginTypeError 200 200 GET /api/csrf(2回目)TypeError 200 200 GET ratesTypeError 200 151.23 200 151.23 GET rates(クッキーなし)TypeError 401 200 # TypeError は「Failed to fetch」。コンソールには blocked by CORS policy: No 'Access-Control-Allow-Origin' header # 設定なしでも、サーバーは GET に 200 で答え、合言葉まで返していた(curl で確認)。読ませなかったのはブラウザ # ログインの POST は、先に OPTIONS(下見)が飛ぶ。設定ありの答え: Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Methods: GET,POST Access-Control-Allow-Headers: x-csrf-token Access-Control-Allow-Credentials: true # ほかのオリジン(http://evil.example)からの下見には 403
設定がないと、すべて TypeError: Failed to fetch でした。おもしろいのは、サーバーはちゃんと 200 で答えていたことです。CORS はサーバーを守る仕組みではなく、ブラウザが「別のオリジンの返事を、ページに読ませてよいか」を決める仕組みです。サーバーが Access-Control-Allow-Origin で許したときだけ、ページは返事を読めます。
/** 第7回:別のオリジン(Vite の 5173)から直接呼ばれるのを許す(--demo.cors-origin=http://localhost:5173 のときだけ) */
@Configuration
@ConditionalOnProperty("demo.cors-origin")
public class CorsDemoConfig {
@Bean
CorsConfigurationSource corsConfigurationSource(@Value("${demo.cors-origin}") String origin) {
CorsConfiguration c = new CorsConfiguration();
c.setAllowedOrigins(List.of(origin)); // * ではなく、オリジンを名指しする
c.setAllowedMethods(List.of("GET", "POST"));
c.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
c.setAllowCredentials(true); // クッキー(ログイン)を付けてよい
UrlBasedCorsConfigurationSource s = new UrlBasedCorsConfigurationSource();
s.registerCorsConfiguration("/api/**", c);
return s;
}
}
クッキーを付けて送るなら、オリジンは * ではなく名指しし、allowCredentials を true に、画面は credentials: 'include' で送ります。付けないと、設定があっても 401 でした。このアプリは本番でも nginx で画面と API を同じオリジンにまとめる予定なので(第15回)、CORS は使いません。
CSRF:ログインで合言葉が変わる
let csrf = null; // 書き込み用の合言葉 { header, token }。ログインすると変わるので、そのたびに取りなおす
/** ログインが切れている(401)ことを知らせる */
export class SignedOut extends Error {}
async function csrfToken() {
csrf ??= await fetch(BASE + '/api/csrf', { credentials: 'include' }).then((r) => r.json());
return csrf;
}
async function call(path, { method = 'GET', body, signal } = {}, retried = false) {
const headers = { 'Accept-Language': T.lang }; // サーバーの言葉(Bean Validation)を画面の言語にそろえる
if (method !== 'GET') {
const c = await csrfToken();
headers[c.header] = c.token; // GET 以外には合言葉を付ける(ないと 403)
}
const res = await fetch(BASE + path, { method, body, headers, signal, credentials: 'include' });
if (res.status === 401) throw new SignedOut();
if (res.status === 403 && method !== 'GET' && !retried) { // 合言葉が古い(ログインが切れた・しなおした)。取りなおして1回だけ送りなおす
csrf = null;
return call(path, { method, body, signal }, true);
}
return res;
}
/** その日の 1ドル何円か。その日のレートがなければ null */
export async function getRate(date, signal) {
const res = await call(`/api/v11/rates?date=${date}`, { signal });
return res.ok ? (await res.json()).rate : null;
}
GET 以外には、/api/csrf でもらった合言葉を X-CSRF-TOKEN に付けます。ほかのサイトに置かれたフォームから勝手に送られても、そのサイトは合言葉を知らないので 403、というのがバックエンド編 第11回の守りでした。
つまずくのは、ログインすると合言葉が変わることです。ログインの前にもらった合言葉で申請を送ると 403 でした(Spring Security が、ログインのときにセッションと合言葉を作りなおすため)。そこで api.js は、ログインしたら覚えていた合言葉を捨て、それでも 403 なら1回だけ取りなおして送りなおします。
401:ログインが切れても消さない
# ログインせずに開いた → まちがったパスワード → 正しいパスワード GET /api/v11/me 401 GET /api/v11/rates 401 GET /api/v11/me 401 GET /api/csrf 200 POST /api/login 401 POST /api/login 200 GET /api/v11/me 200 GET /api/v11/rates 200 # まちがえたあと:ユーザー名の欄は「arai」のまま、パスワードの欄は空 # 申請を書いてから、クッキーを消して(ログインが切れた)「申請する」 GET /api/csrf 200 POST /api/v11/claims 401 GET /api/v11/me 401 # 画面:ログインの欄が出た。件名・領収書(1 個)はそのまま # ログインしなおして、もう一度「申請する」 POST /api/login 200 GET /api/v11/rates 200 GET /api/v11/me 200 GET /api/csrf 200 POST /api/v11/claims 201
ログインしていないと、me もレートも 401 でした。getMe() は 401 を「ログインしていない(null)」と読みかえ、Account はログインの欄を出します。ログインの欄は <form action> のままにしました。まちがえたあとでパスワードの欄が空になるのは好都合で、ユーザー名だけは第6回の②(返した値を defaultValue で戻す)で残しています。
表紙の写真は、申請を書いている途中でログインが切れた場面です。送ると 401 が返り、Action はログインの欄を出させて「入力はそのまま残っています」と返します。第6回で onSubmit から送る形にしておいたので、件名も領収書のファイルも残りました。ログインしなおして押すと 201 です。
# 2行目を 25800 にして合計 55,643 円、備考は空 画面のチェック:送信なし ?check=off:GET /api/csrf 200 → POST /api/v11/claims 400 合計が5万円を超えるので、備考に理由を書いてください。 # サーバーは "reason required" と短い名前で返し、画面が同じ文に直した
第6回の宿題だった「内容は100文字まで」と「5万円を超えたら理由」も、画面とサーバーの両方に入れました。
# npm run build の JavaScript(第6回 → 第7回) index-*.js 239.35 kB → 246.10 kB gzip 75.95 kB → 78.19 kB
自分の PC で
二の窓口を足したバックエンド編 第15回のアプリを SEED=true docker compose up で起こし、そのとき入る arai(パスワード arai-pass-2026)でログインします。React のプロジェクトで npm run dev を実行し、開発者ツールの Network タブで、開いたときにレートの問い合わせが2回飛び、1回が「(canceled)」になるのを見てください。npm run build と npm run preview では1回です。
五の実験は /lab.html?lab=race&how=naive(ignore・abort も)、六は ?lab=uncached&how=inside です。inside は問い合わせを止めないので、見たらすぐタブを閉じてください。ログインが切れた場面は、開発者ツールの Application タブで JSESSIONID のクッキーを消すと作れます。
結 — 一枚まとめと、次回
- 問い合わせは useEffect に書く。依存の配列が変わったときだけ動き、後片付けで途中のものを止める。
- 開発用の StrictMode は effect を2回動かす(本番用は1回)。後片付けがあれば結果は同じ。
- 返事の順番は入れかわる。止めるか捨てる、できれば日付ごとに置いて順番に左右されない形に。
- use と Suspense は待つ範囲を決められる。Promise は部品の外で1回だけ作る(中で作ると3秒で317回)。
- CORS はブラウザの決まり。CSRF の合言葉はログインで変わる。401 では入力を消さずに、ログインの欄を出す。
新井つながりました! でも、1文字打つたびに部品が全部描きなおされるのは、そのままですよね。
古井次回は再描画のコスト。行を増やして Profiler で測り、memo と React Compiler で、どこまで減るかを見る。
参考
- React — useEffect
- React — Synchronizing with Effects
- React — You Might Not Need an Effect
- React — use
- React — <Suspense>
- MDN — Cross-Origin Resource Sharing (CORS)
- MDN — AbortController
- Spring Security — Cross Site Request Forgery (CSRF)
- Spring Security — CORS
記録は、筆者がクラウド上の Linux で、2026年11月2日に取ったものです。第6回のプロジェクト(Vite 8.3.5、React 19.3.1)を、バックエンド編 第15回の Spring Boot 4.1.2(Spring Security 7.1.2、PostgreSQL 16、fw7 の窓口を足したもの)につなぎました。開発用(npm run dev)と本番用(npm run build を npm run preview で配ったもの)を、Playwright で動かした Chromium 153.0.8010.12 で開いています。レートは練習用の値で、本物の相場ではありません。四〜七の実験は lab.html の別の画面で行いました。
いにしえのフロントエンド入門 フレームワーク編 第7回 — 副作用とデータ取得 / 2026.11