React 18 / 19 の理解
モチベ
React 18、 19 を理解する
※ Next.js 16 を使って試していく
参考
お品書き
React 18
createRoot/hydrateRoot/ ストリーミング API は Next.js が内部処理するため対象外
-
useTransition/startTransition— 緊急度の低い更新を後回しにして UI の応答性を保つ(例: 検索入力しながら結果を更新) -
useDeferredValue— 値の反映を遅延させる(useTransitionの値版。リスト絞り込みなどに使う) -
useId— SSR/CSR で一致する一意 ID を生成(htmlFor/aria-describedbyなどに使う) - 自動バッチング — 複数の setState が1レンダーにまとまる仕組みを把握(意図せず依存している箇所を見つけるため)
- StrictMode の挙動変化 — 開発時に
useEffectが2回実行される理由と対処法を把握 - Suspense の使い方 —
loading.tsxに任せず自分で<Suspense>を書く場面を理解
React 19
preload/preinit/ スタイルシート優先度 は Next.js の<Image>/<Script>で代替されるため対象外
RSC / Server Actions は Phase 1 で習得済みのため概念整理のみ
-
useActionState— フォーム送信の pending / error / result を1フックで管理(Server Actions と組み合わせる) -
<form action={fn}>— フォームのactionに Server Actions を渡す(onSubmit不要) -
useFormStatus— 送信ボタンなど子コンポーネントが親フォームの pending を参照 -
useOptimistic— リクエスト完了前に UI を先行更新し、失敗時に自動ロールバック -
use(promise)— Client Component のレンダー中にプロミスを読む(条件分岐内でも使える点が他フックと違う) -
refが props に —forwardRef廃止。コンポーネント定義の簡素化を体験 -
<Context>がプロバイダに —<ThemeContext.Provider>→<ThemeContext>への書き換え
React 18 で追加された機能群
useTransition
setStateをラップすることで、重い描画に対して遅延処理をかけ、ユーザー体験向上を図るためのhooks。
useTransitionから isPending, startTransitionメソッドが利用できる。
isPendingは、startTransitionに渡されたコールバックが完了するまでの間、trueになる。
startTransitionでsetStateをラップすることで遅延処理をかけることができる。
startTransitionが遅延するのは、setStateによって引き起こされるレンダリングに対してのみで、JSの計算そのものに対する遅延処理はかけることが出来ない。
"use client";
/**
* useTransition Sample
* テキスト入力中に、UIがカクつくような重い処理をuseTransitionでラップすると、入力の快適さを保ったまま重い処理を実行できる
*/
import { useState, useTransition } from "react";
const ITEMS = Array.from({ length: 1000 }, (_, i) => `アイテム ${i}`);
export default function TransitionDemo() {
const [query, setQuery] = useState("");
const [filtered, setFiltered] = useState(ITEMS);
const [isPending, startTransition] = useTransition();
function SlowItem({ text }: { text: string }) {
const start = Date.now();
while (Date.now() - start < 1) {} // 遅延処理
return <li>{text}</li>;
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
// setFiltered(ITEMS.filter((item) => item.includes(value)));
// 以下のような描画に影響のある処理をstartTransitionでラップすると、入力を妨げない
startTransition(() => {
setFiltered(ITEMS.filter((item) => item.includes(value)));
});
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="絞り込み" />
{isPending && <p>Pending...</p>}
<ul>
{filtered.map((item) => (
<SlowItem key={item} text={item} />
))}
</ul>
</div>
);
}
startTransitionだけ扱いたい場合、 import {startTransition} from react で利用可能
useDeferredValue
useTransitionと同じく表示を遅延することで、ユーザー体験向上につなげるためのhooks。
useTransitionを扱う際はset関数を丸ごとラップしていたが、useDeferredValueの場合はstateをラップして使用する。
useDeferredValueでstateをラップした場合、そのstateの即座の表示とそのstateが絡むコンポーネントの更新を切り離す事ができる。
例えば、あるstateが別のコンポーネントにpropsとして渡されていて、そのpropsが検索処理などの重い処理に使われていた場合、useDeferredValueを利用していないと、propsが重い描画処理に巻き込まれてしまい、ユーザが入力した値が即座に画面に反映されず体験を損ねる形になってしまうが、useDeferredValueを利用した場合は、ユーザが入力した値の表示を即座に画面に反映できるので、ユーザー体験の向上につながる。
"use client";
/**
* useDeferredValue Sample
*/
import { useState, useDeferredValue } from "react";
const ITEMS = Array.from({ length: 1000 }, (_, i) => `アイテム ${i}`);
function SlowItem({ text }: { text: string }) {
const start = Date.now();
while (Date.now() - start < 1) {}
return <li>{text}</li>;
}
function HeavyList({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const filtered = ITEMS.filter((item) => item.includes(deferredQuery));
return (
<>
{/* <p>useDeferredValueなし:{query}</p> */}
<p>useDeferredValueあり:{deferredQuery}</p>
<ul>
{filtered.map((item) => (
<SlowItem key={item} text={item} />
))}
</ul>
</>
);
}
export default function DeferredDemo() {
const [query, setQuery] = useState("");
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="絞り込み"
/>
<HeavyList query={query} />
</div>
);
}
useId
idに固定文字を書いた場合、そのコンポーネントを2度利用すると、同じidが2つ存在することになるが、これだとhtmlForなどに利用することが出来ない。
useIdを利用することで、自動でIDを生成し、HTML仕様を満たし、htmlForを正しく紐づけたりすることが可能。
useIdを利用しない場合
function EmailField() {
return (
<>
<label htmlFor="email">メール</label>
<input id="email" type="email" />
</>
)
}
↓とした場合、id="email" が2つ存在し、HTML仕様違反かつ、idを利用した処理で正しく動作しない。
<EmailField />
<EmailField />
useId を利用する場合
import { useId } from 'react'
function EmailField() {
const id = useId()
return (
<>
<label htmlFor={id}>メール</label>
<input id={id} type="email" />
</>
)
}
<EmailField /> // 別々のid
<EmailField /> // 別々のid
また、useIdを利用すると、動的に異なるIDが作成されるだけでなく、SSRとCSRでも同じIDを生成することが保証される。
自動バッチング
バッチングとは、複数のsetStateをまとめて1回のレンダーに最適化。
React17以前
function handleClick() {
setCount(c => c+1)
setFlag(f => !f)
// ここで一度だけまとめてレンダー
}
ただし、setTimeout, Promise, ネイティブイベント内ではバッチングが実行されない。
setTimeout(() => {
setCount(c => c+1) // レンダー
setFlag(f => !f) // レンダー
}, 200)
これは、イベントハンドラ実行時にバッチングフラグがたっているため、
バッチングのターゲットがイベントハンドラに含まれるsetStateのみが対象になっていた。
onClick 発火
└─ React がキャッチ
└─ バッチングフラグ ON
└─ setCount() → キューに積む
└─ setFlag() → キューに積む
└─ バッチングフラグ OFF
└─ キューをまとめて1回レンダー
なので、Reactのイベントハンドラ外にあるsetTimeout, Promiseでは、バッチングフラグがOFFの状態なので、キューに積まれず、即レンダーを走らせるという動きになっている。
React18から
Schedulerという仕組みに変更され、バッチングがフラグ中かどうかではなく、同一マイクロタスク/タスク内の更新はすべてまとめるという方針になった。
これにより、setTimeout, Promiseに関係なく自動バッチングが動作するようになった。
参考
Fiber
Schedulerと密接に連動しているとのことだったので、Fiberについて調査。
React 16で導入されたリコンシリエーション(仮想DOMの差分検知アルゴリズム)アーキテクチャ。
React の各コンポーネントをFiberノードという単位で管理することで、React 15以前のレンダリングよりも細かく制御することが可能になった。
なぜこれが必要か?
Reactは、現在の仮想DOMと新しい仮想DOMのツリー構造を比較し、差分検知するアルゴリズム(リコンシリエーション)で、必要最小限の描画が実現できている。
Fiber導入前は、ReconciliationのStack Reconcilerというアルゴリズムが使われており、同期的に差分計算を行うためスレッドを占有する形になっており、また途中中断することもできなかった。
これにより、すべての差分計算が終わるためイベントが待ち状態になってしまう問題が発生するため、小規模のReactアプリでは問題にならないものの、大規模のReactアプリではパフォーマンス面で問題になってしまう。
そのため、各コンポーネントをFiberノードという単位で分割し、Fiberノード単位で小さく差分計算をできるようにすることで、差分計算の途中中断、再開も可能にした。
このReact 16以降のリコンシリエーションのアルゴリズムをFiber Reconcilerと呼ぶ。
Stack Reconciler(React 15以前)
setState() 発火
│
▼
差分計算開始(同期・中断不可)
│
├─ App を処理
│ └─ Header を処理
│ └─ Nav を処理 ←─ ここで時間がかかっても止められない
│ └─ ...
│ └─ ...(再帰的に全部終わるまで走り続ける)
│
▼
DOM 更新
この間、ユーザー入力・アニメーションはすべて待ち状態
Fiber Reconciler(React 16以降)
setState() 発火
│
▼
Scheduler が優先度を判定
│
▼
Fiber ノード単位でループ処理(中断可能)
[App] → [Header] → [Nav] → [List] → [Item] → ...
↑
│ 「時間切れ or 高優先度の割り込みあり」で中断
│
▼
ユーザー入力など高優先度タスクを先に処理
│
▼
中断した [Nav] から再開
│
▼
全ノード処理完了 → DOM 更新
構造の違い
Stack Reconciler Fiber Reconciler
───────────────── ─────────────────
再帰呼び出し ループ(ポインタ移動)
コールスタック Fiber ノード(リンクリスト)
┌─────────┐ ┌──────┐ child ┌──────┐
│ Item │ │ App │ ───────▶ │Header│
├─────────┤ └──────┘ └──────┘
│ List │ │ return │ sibling
├─────────┤ ▼ ▼
│ Nav │ 「どこまで ┌──────┐
├─────────┤ 処理したか」 │ Nav │
│ Header │ を保持できる └──────┘
├─────────┤
│ App │ → 任意の場所で中断・再開可能
└─────────┘
途中停止不可
参考
StrictMode の挙動変化
開発環境でuseEffectが2回実行されるようになった。
React 18から冪等性の観点から何度マウント、アンマウントを繰り返しても副作用が発生しないように、壊れないようにするため、マウント、アンマウント、再マウントを繰り返すことで開発時にクリーンアップが正しく書けているか検証する意味がある。
// NG: クリーンアップなし
useEffect(() => {
window.addEventListener('resize', handleResize)
}, [])
// 2回実行されると listener が2つ登録される
// OK: クリーンアップあり
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// アンマウント時に解除 → 再マウントで1つだけ登録される
next devでは2度実行されるが、本番環境(next build + next start)では1度だけ。
Next.jsではデフォルトでstrictModeが有効になっている。
Suspense
Next.jsの場合、loading.tsxを置くだけでローディングUIが表示される。
これはNext.jsが内部で<Suspense>をラップしているだけ。
Suspenseが動作する条件
- RSCでのasync/await
- React.lazy()
- use(promise)
1. RSCでのasync/await
nextjs/src/app/demo/suspense/SlowComponent.tsx
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export default async function SlowComponent({ ms }: { ms: number }) {
await sleep(ms);
return <p>2秒後に表示されるコンテンツ</p>;
}
nextjs/src/app/demo/suspense/page.tsx
import { Suspense } from "react";
import SlowComponent from "./SlowComponent";
export default function Page() {
return (
<div>
<h1>Suspenseテスト</h1>
<Suspense fallback={<div>Loading...</div>}>
<SlowComponent ms={2000} />
</Suspense>
</div>
);
}
2. React.lazy()
Next.jsで対応する場合は、React.lazy() と Suspense をラップしたnext/dynamicを利用する。
next/dynamicはデフォルトでサーバ側で処理されるため、Suspenseの挙動が体感できない。
そのため、、クライアントで処理されるようするためにssr: falseを記述し、page.tsx からコンポジションパターンでラップしたコンポーネント内でnext/dynamicを利用する。(page.tsxはRSCオンリーになるため)
nextjs/src/app/demo/suspense2/page.tsx
import HeavyComponentWrapper from "./HeavyComponentWrapper";
export default function Page() {
return <HeavyComponentWrapper />;
}
nextjs/src/app/demo/suspense2/HeavyComponentWrapper.tsx
"use client";
import dynamic from "next/dynamic";
const HeavyComponent = dynamic(() => import("./HeavyComponent"), {
ssr: false,
loading: () => <div>Loading...</div>,
});
export default function Page() {
return <HeavyComponent />;
}
nextjs/src/app/demo/suspense2/HeavyComponent.tsx
"use client";
export default function HeavyComponent() {
return <p>Heavy component content</p>;
}
use(promise) については、React 19の理解でまとめる。
React 19
Actions
transition 内で実行される非同期関数を「Action」と呼ぶ。
startTransition(async () => { // Action
await saveData(init) // 非同期処理
setResult('保存完了')
})
なぜ導入されたか?
react 18 以前、フォーム送信のような「非同期処理 → UI更新」のパターンは自前で状態管理が必要だった。
React18以前
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState(null)
async function handleSubmit() {
setIsPending(true)
try {
await saveData(input)
} catch (e) {
setError(e)
} finally {
setIsPending(false)
}
}
React 19のActionはこの提携コードを各フックが自動管理する
- useActionState → pending / error / result を1つのフックで管理
- useOptimistic → 楽観的更新を自動管理
- useFormStatus → 親フォームのpendingを子から参照
<form action={fn}>
// fnがActionとして扱われる
<form action ={async (formData) => {
await saveData(formData.get('title'))
}}>
onSubmitの代わりにactionに非同期関数を渡せる。
Next.js の Server Actions と構文は同じでも実行場所が異なる。
'use server' や 'use client' のディレクティブを意識すれば良い。
useActionState
フォーム送信の pending / error / result を1つで管理できるフック。
const [state, formAction, isPending] = useActionState(fn, initialState)
state → fnの戻り値
formAction → <form action={}>に渡す関数
isPending → 送信中かどうか
React 18以前との比較
// React 18以前(自前で管理)
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
// React19
const [state, formAction, isPending] = useActionState(fn, null)
Server Actionと組み合わせた記述
action.ts
"use server";
export async function submitPost(
prevState: { error?: string; success?: boolean } | null,
formData: FormData,
) {
const title = formData.get("title") as string;
if (!title) return { error: "タイトルは必須" };
// 送信の遅延をシミュレート
await new Promise((resolve) => setTimeout(resolve, 1000));
return { success: true };
}
page.tsx
import PostForm from "./PostForm";
export default function Page() {
return (
<div>
<PostForm />{" "}
</div>
);
}
PostForm.tsx
"use client";
import { useActionState } from "react";
import { submitPost } from "./action";
export default function PostForm() {
const [state, formAction, isPending] = useActionState(submitPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="タイトル" />
<button type="submit" disabled={isPending}>
{isPending ? "送信中..." : "送信"}
</button>
{state?.error && <p style={{ color: "red" }}>{state.error}</p>}
{state?.success && <p style={{ color: "green" }}>送信成功!</p>}
</form>
);
}
React18以前の形で記述する場合
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsPending(true)
try {
submitPost(title)
setSuccess(true)
} catch {
setError('エラーが発生')
} finaly {
setIsPending(false)
}
}
<form onSubmit={handleSumit}>
React 19の場合
const [state, formAction, isPending] = useActionState(submitPost, null)
<form action={formAction}>
useFormStatus
親フォームの送信状態を子コンポーネントから参照するフック。
なぜ必要か
送信ボタンを別コンポーネントに切り出した時、isPendingをpropsで渡す必要がなくなる。
<form action={formAction}>
<SubmitButton /> {/* props不要 */}
</form>
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>送信</button>
}
useFormStatusを使うコンポーネントは<form>の子コンポーネントである必要がある。
同じファイルの<form>と同階層では動作しないので、useFormStatusを利用した汎用コンポーネントを作成する場合は、formの中で利用するルールが必要。
useOptimistic
リクエスト完了前に UI を先行更新し、失敗時に自動ロールバック(楽観的更新)するフック。
なぜ必要か
通常のフォーム送信はサーバの応答を待ってからUIを更新するが、楽観的更新はサーバの応答を待たずに先にUIを更新する。
送信 → UI即更新 → サーバ応答 → 成功:そのまま / 失敗:ロールバック
基本構文
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn)
optimisticState → 楽観的に更新されたstate(表示に利用)
addOptimistic → 楽観的更新を発火する関数
state → 実際のstate
updateFn → (currentState, optimisticValue) => newState
実装
"use client";
import { useOptimistic, useTransition, useState } from "react";
import { addPost } from "./action";
type Post = { id: number; title: string };
export default function PostList({ initialPosts }: { initialPosts: Post[] }) {
const [posts, setPosts] = useState(initialPosts);
const [isPending, startTransition] = useTransition();
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(current, newTitle: string) => [
...current,
{ id: Date.now(), title: `${newTitle}(送信中...)` },
],
);
function handleSubmit(formData: FormData) {
const title = formData.get("title") as string;
startTransition(async () => {
addOptimisticPost(title);
const newPost = await addPost(formData);
setPosts((prev) => [...prev, newPost]);
});
}
return (
<div>
<form action={handleSubmit}>
<input type="text" name="title" placeholder="タイトル" />
<button type="submit" disabled={isPending}>
追加
</button>
</form>
<hr />
<ul>
{optimisticPosts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</div>
);
}
use(promise)
クライアントコンポーネントのレンダー中にPromiseを読むAPI
// 通常のフックは条件分岐内で使えない
if(condition) {
const [state] = useState(...) // NG
}
// use() は条件分岐内でも使える
if(condition) {
const data = use(promise) // OK
}
実装
PostList.tsx
"use client";
import { use } from "react";
type Post = {
id: number;
title: string;
};
export default function PostList({
postPromise,
}: {
postPromise: Promise<Post[]>;
}) {
const posts = use(postPromise);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
page.tsx
import { Suspense } from "react";
import PostList from "./PostList";
async function fetchPosts() {
const res = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5",
);
return res.json();
}
export default function Page() {
const postsPromise = fetchPosts();
return (
<Suspense fallback="Loading...">
<PostList postPromise={postsPromise} />
</Suspense>
);
}
- page.tsxでawaitせずにPromiseのままクライアントコンポーネントにわたす
- use()がPromiseを受け取ると、Suspenseのfallbackに処理が流れる
- Promiseが解決されたらpostsに値が入って描画される
- クライアントコンポーネントでサーバのデータを受け取りつつ、ローディングはSuspenseに任せられる
どういった旨味がある?
RSCのasync/awaitと同じような動作をクライアントコンポーネントで実現できる。
RSCの場合、useStateを扱うことが出来ないが、クライアントコンポーネントでは扱うことができるため、stateを扱いたいかつ、Suspenseも扱いたい時にuse(promise)が利用できる。
あとは、通常のフックだと早期リターンよりも前に利用する必要があるが、use(promise)の場合は、早期リターンの後でも利用することができる。
条件分岐内で使える意味
'use client'
export default function PostList({ postsPromise, showDetails }) {
if (!showDetails) {
return <p>詳細なし</p> // ← ここで早期リターン
}
// 早期リターンの後でも use() は使える
const posts = use(postsPromise)
return <ul>...</ul>
}
データ取得はRSC、インタラクションはクライアントというApp Router の設計思想をそのまま実現することができるフック。
ref が props に
forwardRef が不要になり、ref を通常のpropsとして受け取れるようになった。
ref = DOMノードやReactコンポーネントのインスタンへの参照を保持するもの。
focus(), scrollIntoView(), getBoundingClientRect()など、Reactのstateでは制御できないDOM操作に使う。
この変更で、親コンポーネントから子コンポーネントのDOMを操りやすくなる。
// React 18以前
const Input = forwardRef<HTMLInputElement, { placeholder: string }>(
(props, ref) => <input ref={ref} {...props} />
)
// React 19
function Input({ref, ...props}: {ref: React.Ref<HTMLInputElement>; placeholder: string}) {
return <input ref={ref} {...props} />
}
forwardRefは残っているが、deprecatedで、将来的には削除予定。
<Context> がプロバイダに
<Context.Provider> を <Context> だけで書けるようになった。
const ThemeContext = createContext('light')
// React 18
<ThemeContext.Provider value="dark">
<App />
</ThemeContext>
// React 19
<ThemeContext value="dark">
<App />
</ThemeContext>