Article

React 18から19への移行で直面した7つの落とし穴と解決策

高田晃太郎
React 18から19への移行で直面した7つの落とし穴と解決策

レガシーコードを含む大規模なReactコードベースでは、メジャーアップグレード時にビルド警告や型エラーに直面しやすいという指摘がコミュニティでも広く見られます。React 19はActions(フォーム送信を関数に直結する新機能)、use(Promise/リソースの直接読み出し)、アセットのプリロード/プリイニット、refの扱い改善などで開発者体験を前進させる一方、既存プロジェクトでは小さな前提のズレが大きな生産性低下につながりがちです。無計画な一括移行はビルド通過までに時間を要し、ホットパスのレンダリングで回帰が発生することもあります。逆に段階導入と計測を併用すると、移行工数の見通しを立てやすく、フォーム送信のUXや安定性も向上させやすくなります。

本稿では、React 18から19へ移行する際にCTOやエンジニアリングマネージャーが直面しやすい7つの落とし穴を、再現ケースと回避策、完全なコード例で解説します。各節は既存アプリに段階導入できる順序で並べ、計測指標とロールバック戦略も示します。

落とし穴1:Form Actionsの前提を誤りクライアントだけで導入してしまう

React 19はフォーム送信を関数に直接バインドできるActionsを導入しました。これはプログレッシブエンハンスメントの観点で強力ですが、フレームワークやサーバ環境(SSRやサーバアクションの橋渡し)が対応していることが前提になります。Next.jsのApp RouterなどActionを橋渡しする仕組みがない純粋なCSR(Client-Side Rendering)アプリに、いきなり

を適用すると、想定どおりにサーバで実行されずUXが悪化します。安全な移行は、まず現行のfetchベース送信を温存し、同等の振る舞いを保ったうえでAction対応フレームワークに移す段階導入が有効です。1

解決策:現在の送信ロジックを保持しつつAction対応を段階導入する

純CSRのままなら、従来のsubmitハンドラを維持してUXを壊さない実装を先に用意します。次にAction対応環境でのフォームを併置し、トグルで比較検証します。

import React, { useState, startTransition } from 'react';

async function postJSON(url: string, body: unknown) {
  const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  if (!res.ok) throw new Error('Request failed');
  return res.json();
}

export function TodoFormCSR() {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  return (
    <form onSubmit={async e => {
      e.preventDefault();
      const form = e.currentTarget as HTMLFormElement;
      const title = new FormData(form).get('title');
      setPending(true);
      setError(null);
      try {
        const data = await postJSON('/api/todos', { title });
        startTransition(() => {
          // 軽量更新は並行で反映
        });
        form.reset();
      } catch (err: any) {
        setError(err.message);
      } finally {
        setPending(false);
      }
    }}>
      <input name="title" placeholder="Add todo" />
      <button disabled={pending}>{pending ? 'Saving…' : 'Add'}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Action対応フレームワークでは同等のUIを保ったまま、フォームのactionに関数を渡します。ファイル送信などはFormDataをそのまま扱えるため、余計なシリアライズ処理を削除できます。1

'use client';
import React from 'react';
import { useFormStatus } from 'react-dom';

// 注意: 実際のサーバ実行はフレームワークのサポートが必要
// 例: Next.js App Router の server actions
export async function createTodo(formData: FormData) {
  'use server';
  const title = formData.get('title');
  // DB保存...
}

function Submit() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving…' : 'Add'}</button>;
}

export function TodoFormAction() {
  return (
    <form action={createTodo}>
      <input name="title" placeholder="Add todo" />
      <Submit />
    </form>
  );
}

この切り替えにより、送信専用コードをクライアントから取り除けるため、バンドルの肥大化を抑えやすくなり、往復の整合性管理も簡素化できます。1

落とし穴2:useの誤用でSuspense境界とSSR整合性が崩れる

React 19のuseはPromiseやContext、リソースオブジェクトを直接読み出せます。魅力的な反面、Suspenseで囲まずに使用したり、サーバ側とクライアント側のフェッチ差分でHTML整合性が崩れる事例が多発します。SSR(サーバサイドレンダリング)後の水和(hydration)エラーの診断は19で改善されていますが、境界が粗いと再燃しやすくなります。4

解決策:fetch層のキャッシュとSuspense境界、エラーバウンダリをセットで導入する

データ取得関数は重複フェッチを避けるキャッシュとともに定義し、読み出し側は必ずSuspenseで包みます。SSRでは同一データソースをサーバ・クライアントで共有し、意図しない再フェッチを避けます。4

import React, { Suspense, use } from 'react';

const cache = new Map<string, Promise<any>>();
function fetchJSON(url: string) {
  if (!cache.has(url)) {
    cache.set(url, fetch(url).then(r => {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    }));
  }
  return cache.get(url)!;
}

function UserPane({ id }: { id: string }) {
  const user = use(fetchJSON(`/api/users/${id}`));
  return <div>{user.name}</div>;
}

export function UserSection({ id }: { id: string }) {
  return (
    <Suspense fallback={<div>Loading…</div>}>
      <UserPane id={id} />
    </Suspense>
  );
}

このパターンに統一すると、初回描画での重複フェッチを避けやすくなり、同一データソースのミスアラインによる水和エラーも抑制しやすくなります。

落とし穴3:資産のプリロード/プリイニットが重複してバンドルが膨らむ

React 19はCSSやフォント、スクリプトなどのアセットを反応的に読み込み制御できるようpreloadやpreinitを提供します。既存の静的や手動インジェクションと併用すると、同一リソースの二重登録でネットワークの無駄が発生します。重複時にブラウザが最適化することもありますが、優先度が競合してかえって遅くなるケースも見られます。2

解決策:react-domのAPIに寄せて一元化し、重複を排除する

エントリポイントでreact-domのAPIを使い、重要資産だけを宣言的に予約します。既存の手動プリロードは順次削除し、ネットワークパネルで重複がないかを確認します。2

import { preinit, preload } from 'react-dom';

// クリティカルCSSを優先読込
preinit('/styles/app.css', { as: 'style' });
// Webフォントはフォーマット指定で確実性を高める
preload('/fonts/inter-var.woff2', { as: 'font', crossOrigin: 'anonymous', type: 'font/woff2' });
// 上位ルートで使うワーカーを先行登録
preload('/workers/search.js', { as: 'script' });

この統合により、初回ビューのCSS取得が最短経路に固定され、CLS(Cumulative Layout Shift)の発生を抑制しやすくなります。重複リンクの排除でHTMLも軽量化できます。

落とし穴4:refを通常のprop名として使っており型崩れや衝突が起きる

React 19では関数コンポーネントがrefを通常のプロップとして受け取れるようになりました。これはWebコンポーネントや外部境界との相互運用に有益ですが、既存コードで独自のpropとしてrefという名前を使っている場合、型や挙動の衝突を招きます。forwardRefと独自のinnerRefを混在させていると、ジェネリクスの解決が壊れることもあります。1 6

解決策:命名を整理し、forwardRefと併用時の型を厳密に定義する

公開APIはrefを正しい意味で予約し、既存の独自refは名称をinnerRefなどに統一します。forwardRefのジェネリクスを明示して安全に移行します。6

import React, { forwardRef } from 'react';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  innerRef?: React.Ref<HTMLButtonElement>; // 既存互換
};

export const PrimaryButton = forwardRef<HTMLButtonElement, ButtonProps>(function PrimaryButton(
  { innerRef, ...props }, ref
) {
  const mergedRef = (node: HTMLButtonElement | null) => {
    if (typeof ref === 'function') ref(node);
    else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
    if (typeof innerRef === 'function') innerRef(node);
    else if (innerRef) (innerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
  };
  return <button {...props} ref={mergedRef} />;
});

命名整理により型の衝突を防ぎ、外部からの参照方法が統一されレビューもしやすくなります。

落とし穴5:並行レンダリング下で重い更新をtransitionに包まずフレーム落ちする

Actionsやデータロードの完了に合わせて重いステート更新を行うと、コンテンツのインタラクティブ性が一時的に失われることがあります。React 18からの並行レンダリングモデルは継続していますが、19ではUIイベントから派生する複合更新が増え、適切にstartTransition(低優先度にする更新)を使わないとフレーム落ちが目立つようになります。5

解決策:ユーザ入力に直結しない更新はtransitionで包み、応答性と一貫性を両立する

入力直結の軽量ステートは即時に、結果リストなど重い計算やフィルタリングはtransitionに分離します。これにより入力遅延を避けつつ描画の整合性を保てます。5

import React, { startTransition, useMemo, useState } from 'react';

type Item = { id: number; title: string; body: string };

export function SearchBox({ items }: { items: Item[] }) {
  const [q, setQ] = useState('');
  const [results, setResults] = useState<Item[]>(items);

  function onInput(e: React.ChangeEvent<HTMLInputElement>) {
    const next = e.target.value;
    setQ(next);
    startTransition(() => {
      const filtered = items.filter(i => i.title.includes(next) || i.body.includes(next));
      setResults(filtered);
    });
  }

  const count = useMemo(() => results.length, [results]);

  return (
    <section>
      <input value={q} onChange={onInput} placeholder="Search" />
      <p>{count} results</p>
      {results.map(r => <article key={r.id}>{r.title}</article>)}
    </section>
  );
}

この分離により、入力時の体感遅延を抑えながら結果の更新も安定させやすくなります。

落とし穴6:型定義とESLintのアップデート順序を誤ってビルドが塞がる

React 19系の@types/react/@types/react-dom、eslint-plugin-react-hooks、eslint-plugin-react-refresh、TypeScriptコンパイラの互換性は移行初期のハードルです。型のbreakingによりSyntheticEventの型違いやchildrenのoptional化で広範囲に波及し、CIが止まるリスクがあります。2 3

解決策:型とリンタの順序を固定し、tsconfigとJSXランタイムを明示する

まずTypeScriptを5.3以降に上げ、続けて@types/react 19系とESLint関連を更新します。JSXランタイムはreact-jsxに固定し、型のエラーを一括で吸収します。移行中はskipLibCheckを一時的に有効化して外部型の揺れに耐性を持たせる判断も現実的です。2

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  }
}

ESLintのルールも合わせて更新し、useEffectの依存配列など自動修正の挙動が変わらないかをチームで確認します。2

{
  "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:react-hooks/recommended"],
  "parserOptions": { "ecmaFeatures": { "jsx": true } },
  "settings": { "react": { "version": "detect" } },
  "rules": {
    "react-hooks/exhaustive-deps": "warn"
  }
}

この順序で進めると、PRの停滞を避けやすく、型修正の差分も整理しやすくなります。

落とし穴7:Suspenseとエラーバウンダリの設計不足で水和エラーが再燃する

React 19は水和まわりの診断が改善されていますが、データ境界とエラー境界を粗く設計すると、SSRの一時的なエラーやタイムアウトでフォールバックがバラバラに見えます。とくに複数のuse読み出しが混在し、どれか一つが失敗した場合、境界設計が甘いとユーザーに意図しない分割が露呈します。関連する既知の水和不整合はIssueとしても議論されています。4 7

解決策:データ単位で境界を切り、失敗はユーザー語彙で説明する

データのまとまりごとにSuspenseとErrorBoundaryを配置し、失敗時のメッセージはドメイン語彙で表現します。失敗をログに送る仕組みと再試行のUIを標準化します。4

import React, { Suspense, use } from 'react';

class SectionErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; message?: string }> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(err: any) {
    return { hasError: true, message: err?.message ?? 'Unknown error' };
  }
  render() {
    if (this.state.hasError) return <div role="alert">在庫情報の取得に失敗しました。後でもう一度お試しください。</div>;
    return this.props.children;
  }
}

function InventoryBody() {
  const data = use(fetch('/api/inventory').then(r => r.json()));
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

export function InventorySection() {
  return (
    <SectionErrorBoundary>
      <Suspense fallback={<div>在庫情報を読み込み中…</div>}>
        <InventoryBody />
      </Suspense>
    </SectionErrorBoundary>
  );
}

この分割により、局所的な失敗でもページ全体が白くなる事態を避けられ、再試行や復旧の導線も提供できます。失敗箇所の特定が明確になり、復旧時間の短縮にもつながります。

移行の順序設計とビジネス効果

React 19の採用は、リリースを止めずに段階導入しながら成果を積み上げる戦略が適しています。2 まずは型定義とリンタの整備で土台を固め、次に資産プリロードの一元化で初期表示を改善します。並行してSuspenseとuseの構成を小さく導入し、最終的にActionsをフレームワーク対応とともに展開します。UXの向上はエスカレーション工数の削減やコンバージョン漏れの防止に直結しうるもので、バンドル削減とネットワーク最適化はCDN費用の抑制にも寄与する可能性があります。

まとめ:計測と段階導入で安全にReact 19の価値を取り込む

React 19の新機能は、イベント処理やデータ取得、資産読込、参照の受け渡しといったアプリの基礎体力を底上げします。1 一方で、フレームワーク前提や型の揺れ、Suspense境界の設計といった実装上の落とし穴も現実に存在します。移行はビッグバンではなく、型とリンタの更新、資産プリロードの統合、useとSuspenseの最小適用、transitionでの応答性確保、そしてActionの段階導入という順序で進めるのが安全です。2

チームの速度を落とさずに品質を上げる鍵は、計測とロールバック可能な小さな変更の積み重ねです。今のプロダクトで最も痛みの大きい箇所を一つ選び、本文のコードをそのまま複製して小さく試してみてください。計測結果をチームで共有し、次の一手を合意形成するところから始めましょう。移行はプロジェクトではなくプロセスです。今日の一歩が、数か月後の開発体験とビジネス成果を確実に変えます。

参考文献

  1. React Team. React 19. React Blog (2024-12-05). https://react.dev/blog/2024/12/05/react-19#:~:text=Actions%20are%20also%20integrated%20with,automatically%20submit%20forms%20with%20Actions
  2. React Team. React 19 Upgrade Guide. React Blog (2024-04-25). https://react.dev/blog/2024/04/25/react-19-upgrade-guide#:~:text=The%20improvements%20added%20to%20React,changes%20to%20impact%20most%20apps
  3. React Team. Breaking changes (React 19 Upgrade Guide). https://react.dev/blog/2024/04/25/react-19-upgrade-guide#:~:text=Breaking%20changes
  4. React 19 — Diffs for hydration errors. https://19.react.dev/blog/2024/04/25/react-19#:~:text=Diffs%20for%20hydration%20errors
  5. startTransition — React Docs. https://react.dev/reference/react/startTransition#:~:text=,blocking%20Transition
  6. refのプロパティ化(React 19の変更点の解説). Frontedge Tech. https://frontedge.tech/237/#:~:text=ref%E3%81%AE%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E5%8C%96
  7. GitHub Issue #28285: Hydration mismatch bug discussion. https://github.com/facebook/react/issues/28285#:~:text=Bug%3A%20,28285