Old-School Front-End · Frameworks / 2026 ・ No.07

いにしえのフロントエンド入門 フレームワーク編

第7回 副作用とデータ取得 ── useEffect、use と Suspense、本物の Spring Boot

公開

ブラウザの画面。React の申請フォームの上に「ログインしてください」の欄が出ている。件名は大阪出張(10/14〜15)、明細は4行で、4行目はタクシー 35.20 ドル。合計 47,953 円。ドルの換算は 2026-10-14 は 1ドル = 150.86円、2026-10-15 は 1ドル = 151.23円(Spring Boot から取得)。領収書のファイルとチェックは入ったまま。下に「ログインが切れていたため、送れませんでした」の赤いお知らせ
ログインが切れたあとで「申請する」を押しました。入力は1つも消えていません(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)

第6回までは、レートは 151.23 の決め打ちで、送り先は練習用サーバーでした。第7回は バックエンド編で作った Spring Boot につなぎ、レートを日付ごとに useEffect で取り、ログインしている人を use と Suspense で読みます。
そのうえで、開発用では問い合わせが2回飛ぶ、返事の順番が入れかわる、CORS で止まる、ログインで合言葉が変わる、ログインが切れる、の5つを実測します。

一

決め打ちをやめる

新井レートを取るなら、App の中で fetch して setRate すればいいんですよね。

古井部品の関数の中でそれを書くと、描くたびに問い合わせて、setRate でまた描く。終わらないぞ。

全15回の路線図(いまここ:第7回)

1 なぜ FW 2 jQuery→MVC 3 React 以後 4 React の一歩 5 状態と再描画 6 フォーム 7 データ取得 8 再描画のコスト 9 状態の置き場所 10 画面遷移 11 楽観的更新 12 Server Comp. 13 キャッシュ 14 テスト 15 公開と選び方 いまここ なぜと歴史 React の基本 速さと置き場所 画面とサーバー Next.js 仕上げ

第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 が動く順番

開発用(StrictMode) 描く 画面に出す effect ① 問い合わせ A 後片付け ① A を止める effect ② 問い合わせ B B の返事 setRates 本番用 描く 画面に出す effect 問い合わせ 返事 setRates あとで、ドルの行の日付が変わると 後片付け(前を止める)→ effect(新しい日付で) effect は「描いたあと」に動き、後片付けは「次の effect の前」と「外すとき」に動く StrictMode は開発用だけ、わざと1回外して付けなおし、後片付けの書き忘れを見つけさせる
# ログインしたあとで申請フォームを開いた(ドルの行は 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つの状態にして、遅い日付と速い日付を続けて選んでみます。

3つの小さなブラウザの画面。どれも 10/14 を選んで、すぐ 10/15 に変えたあと。① 後片付けなしは、日付は 2026-10-15 なのに 1ドル = 150.86円(2026-10-14 のレート)。② ignore フラグと ③ AbortController は 151.23円(2026-10-15 のレート)
10/14 の返事だけ、わざと1.2秒遅らせました。①は遅れて届いた返事が、あとから上書きしています(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 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通り

ブラウザ(ページは http://localhost:5173) クッキー:JSESSIONID(ログイン) Vite(5173) /api を 8080 へ取り次ぐ OPTIONS(下見) Allow-Origin がないと止める Spring Boot(8080) 合言葉(CSRF)はセッションごと fetch('/api/…') 同じオリジン fetch('http://localhost:8080/…') 別のオリジン 開発中は左(Vite が取り次ぐ)。本番は nginx が同じ働きをする(第15回) 右にするなら、サーバーで許すオリジンを名指しし、credentials: 'include' で送る
  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回の守りでした。

端末の画面。curl で、ログインの前に合言葉(CSRF トークン)をもらい、その合言葉でログインすると 200。同じ合言葉で申請を送ると 403。合言葉を取りなおして送ると 201 で、受付番号 EXP-2026-0012、合計 14720
ログインすると合言葉が変わります。古い合言葉の申請は 403 で断られました(筆者がクラウド上の Linux で、2026年11月2日に撮影。ウインドウの上の帯は説明のために描き足したもの)

つまずくのは、ログインすると合言葉が変わることです。ログインの前にもらった合言葉で申請を送ると 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 のクッキーを消すと作れます。

結 — 一枚まとめと、次回

  1. 問い合わせは useEffect に書く。依存の配列が変わったときだけ動き、後片付けで途中のものを止める。
  2. 開発用の StrictMode は effect を2回動かす(本番用は1回)。後片付けがあれば結果は同じ。
  3. 返事の順番は入れかわる。止めるか捨てる、できれば日付ごとに置いて順番に左右されない形に。
  4. use と Suspense は待つ範囲を決められる。Promise は部品の外で1回だけ作る(中で作ると3秒で317回)。
  5. CORS はブラウザの決まり。CSRF の合言葉はログインで変わる。401 では入力を消さずに、ログインの欄を出す。

新井つながりました! でも、1文字打つたびに部品が全部描きなおされるのは、そのままですよね。

古井次回は再描画のコスト。行を増やして Profiler で測り、memo と React Compiler で、どこまで減るかを見る。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ