React

React Hooksの使い方|useState・useEffectの基本とReact 19.2の新フック

React Hooksは、関数コンポーネントに状態や副作用を持たせるための仕組みです。使い方そのものは useStateuseEffect の2つを覚えれば動きますが、実務でつまずくのは「更新が1回分しか反映されない」「依存配列に何を入れるべきか」「メモ化しても再レンダーが止まらない」といった挙動の側です。

説明はすべて、React 19.2.8(2026-08-07時点の最新安定版)で実際に動かして得られた出力に基づきます。掲載コードは、この形のまま jsdom 上で実行して結果を確認したものです(可読性のため型注釈を省いたJavaScript表記。型注釈を加えた版を @types/react 19.2.18 と strict 設定で別途検査しています)。

まとめ:フック選択の判断基準と19.2時点の変更点

画面に出す値は useState、画面に出さない値は useRef。この一線がフック選択の出発点です。useRef の書き換えは再レンダーを起こさないため、表示に使う値を入れると画面が更新されません。

副作用は「外部システムとの同期」に限定します。propsやstateから計算できる値はレンダー中に計算するか useMemo に置き、Effectでstateを更新して同期させる書き方は避けます。eslint-plugin-react-hooks 7.1.1 の推奨設定も、この書き方を set-state-in-effect でエラー扱いにしています。

メモ化の位置づけは2025年10月のReact Compiler 1.0で変わり、手動の useMemo / useCallback は「コンパイラを入れられない環境の手段」に後退しました。ただし依存配列の考え方は useEffect に残ります。

React Hooks全体像と19.2で使える組み込みフック一覧

React 19.2.8 の react がエクスポートするフックは19個、これに react-domuseFormStatus を加えた20個が組み込みフックです(パッケージを読み込んで use で始まるエクスポートを列挙して確認)。用途別に並べると次のとおりです。

用途 フック 使いどころ
状態 useState / useReducer 画面に出す値の保持
共有 useContext / use Contextの配布・Promiseの読み取り
副作用 useEffect / useLayoutEffect / useInsertionEffect / useEffectEvent 外部システムとの同期(useInsertionEffectはCSS-in-JS用)
参照 useRef / useImperativeHandle DOM操作・非表示の値
性能 useMemo / useCallback / useTransition / useDeferredValue 再計算と再レンダーの抑制
フォーム useActionState / useOptimistic / useFormStatus 送信状態と楽観的更新
その他 useId / useSyncExternalStore / useDebugValue ID生成・外部ストア購読

どのフックにも共通する制約が「コンポーネントまたはカスタムフックのトップレベルでのみ呼ぶ」というルールです。条件分岐やループの中で呼ぶと、レンダーごとに呼び出し個数が変わり、Reactが内部の対応付けを失います。

function Broken({ show }) {
  if (show) {
    const [extra] = useState('条件付き');   // 規約違反:条件分岐の中で呼んでいる
    if (extra === '') return null;
  }
  const [count] = useState(0);
  return <p>{count}</p>;
}

このコンポーネントを show が true の状態で描画してから false に変えて再レンダーさせると、React 19.2.8 は次の例外を投げて描画を止めました。

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

例外は React 19 の use です。use はフックのルールのうち「トップレベルでのみ呼ぶ」制約だけが外れており、if文やループの中でも呼べます(後述)。それ以外のフックは、早期returnより前に全て呼び切る形に書き換えてください。

useStateの更新が1回分しか反映されない原因と対処

useState は現在値と更新関数の配列を返します。const [count, setCount] = useState(0)0 が初期値です。押さえるべきは、count が「そのレンダー時点で固定された値」である点です。

関数型更新が必要になる条件

同じイベントハンドラの中で複数回更新する場合、直接値を渡す書き方は意図どおりに動きません。次の2つのハンドラを用意し、それぞれ1回ずつクリックしたときの表示を確認しました。

function Counter() {
  const [count, setCount] = useState(0);

  const addThree = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  const addThreeSafely = () => {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  };

  return (
    <>
      <p>{count}</p>
      <button onClick={addThree}>直接指定</button>
      <button onClick={addThreeSafely}>関数型更新</button>
    </>
  );
}

実行結果は、直接指定のボタンを押した後の表示が 1、続けて関数型更新のボタンを押した後が 4 でした。直接指定では3回とも同じ count(0)を参照するため加算は1回分にしかならず、関数型更新では直前の更新結果が引数 c に渡るため3回分が積み上がります。前の値に依存する更新は、必ず関数型更新で書いてください。

初期化子による初回だけの計算

useState(heavyInit()) と書くと、初期値が使われない2回目以降のレンダーでも heavyInit() が毎回実行されます。呼び出さずに関数そのものを渡すと、Reactが初回レンダー時にだけ実行します。

const [form, setForm] = useState(heavyInit);   // カッコを付けない

この形で入力を発生させ再レンダーを起こしたあとも、heavyInit の呼び出し回数は1回のままでした。JSONのパースやローカルストレージの読み込みなど、初期値の生成が重い場合に効きます。

useEffectの依存配列・クリーンアップ・実行タイミング

useEffect は、レンダー結果が画面に反映された後に実行される処理を登録します。react.dev が定める用途は「外部システムとの同期」で、タイマー、イベントリスナー、WebSocket接続、React管理外のライブラリ操作が該当します。データの整形や派生値の計算は副作用ではないので、Effectには入れません。

依存配列3パターンとクリーンアップの実行順

第2引数の依存配列は、省略すると毎レンダー後、空配列ならマウント時のみ、値を並べればその値が変化したときに実行されます。戻り値の関数はクリーンアップとして、次回のセットアップ直前とアンマウント時に呼ばれます。

function RoomStatus({ roomId }) {
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const conn = connect(roomId);      // セットアップ
    setConnected(true);
    return () => {                     // クリーンアップ
      conn.disconnect();
      setConnected(false);
    };
  }, [roomId]);                        // roomId が変わるたびに貼り直す

  return <p>{connected ? '接続中' : '切断'}</p>;
}

roomId を general から random へ変えたところ、記録されたログは disconnect:generalconnect:random の順でした。古い接続を閉じてから新しい接続を張る順序が保証されています。逆に言えば、クリーンアップを書かないEffectは roomId が変わるたびに接続が積み上がります。

Strict Modeでの二重実行と接続処理の耐性

開発ビルドの <StrictMode> 配下では、マウント直後のログが connect:generaldisconnect:generalconnect:general となりました。Reactが意図的にセットアップとクリーンアップを1往復追加で実行し、クリーンアップの書き漏れを検出させる挙動です。本番ビルドでは起きません。

「開発中だけAPIが2回呼ばれる」現象の正体はこれです。フラグ変数で2回目を握りつぶすのではなく、クリーンアップがセットアップを打ち消せているかを直してください。二重実行で壊れるEffectは、実際の再マウントでも壊れます。

データ取得時の競合状態と破棄フラグ

Effect内でデータを取得する場合、応答が返る順序は呼び出し順と一致しません。破棄フラグを閉じ込めて、古い応答を捨てます。

function Profile({ userId }) {
  const [bio, setBio] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetchBio(userId).then((result) => {
      if (!ignore) setBio(result);     // 古い応答は捨てる
    });
    return () => { ignore = true; };
  }, [userId]);

  return <p>{bio ?? '読み込み中'}</p>;
}

alice の応答を60ms、bob を5ms に固定し、alice の直後に bob へ切り替えたところ、表示は「bobの自己紹介」で確定しました。フラグを外すと、後着の alice が bob を上書きします。

もっとも、キャッシュ・再検証・重複排除まで自前で書き続けるのは割に合いません。取得処理が画面をまたいで増えた段階で、TanStack Queryのようなライブラリへ寄せる判断が現実的です。

useEffectEventによる依存から外したイベント処理

React 19.2で正式版になった useEffectEvent は、「Effectの中から呼ぶが、依存配列には含めたくない処理」を切り出します。従来はテーマや通知設定を参照するだけで依存が増え、無関係な再接続が発生していました。

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('接続しました', theme);   // 最新の theme を読む
  });

  useEffect(() => {
    const connection = connect(roomId);
    connection.on('connected', () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);                                 // theme は依存に不要

  return <p>{roomId}</p>;
}

theme を light から dark に変えても再接続は起きず、接続回数は1回のままでした。その後 roomId を変えて再接続したときの通知は、依存に入れていない theme の最新値 dark を読み取っています。useEffectEvent が返す関数はEffectの内側からのみ呼べる制約があるため、イベントハンドラへ直接渡す用途には使えません。19.2の他の追加点はReact 19.2の新機能総まとめで扱っています。

useRefとuseStateの違いと使い分けの基準

useRefcurrent プロパティを持つ箱を返し、その中身は再レンダーをまたいで保持されます。useState との決定的な違いは、書き換えても再レンダーが起きない点です。

観点 useState useRef
更新時の再レンダー 起きる 起きない
更新方法 setterを呼ぶ current を直接代入
レンダー中の読み書き 読み取り可・書き込み不可 初期化以外は不可
主な用途 画面に出す値 DOM参照・タイマーID

実測では、countRef.current += 1 を3回実行しても再レンダー回数は1回のままで、画面表示は0のままでした。その後 state を1回更新すると再レンダーが走り、そこで初めて ref の値が3として表示されます。「値は増えているのに画面が変わらない」という不具合の典型パターンです。

function Stopwatch() {
  const [now, setNow] = useState(0);
  const intervalRef = useRef(null);
  const inputRef = useRef(null);

  const start = () => {
    intervalRef.current = setInterval(() => setNow((n) => n + 1), 10);
    inputRef.current?.focus();       // DOMへの命令的な操作
  };
  const stop = () => {
    if (intervalRef.current) clearInterval(intervalRef.current);
  };
  useEffect(() => stop, []);

  return (
    <>
      <input ref={inputRef} />
      <p>{now}</p>
      <button onClick={start}>開始</button>
      <button onClick={stop}>停止</button>
    </>
  );
}

タイマーIDのような「保持したいが表示しない値」と、フォーカス移動などのDOM操作が useRef の担当です。react.dev の useRef リファレンスは「初期化を除き、レンダー中に ref.current を読み書きしてはいけない」と明記しています。React 19では ref を通常のpropsとして子に渡せるようになり、forwardRef は不要になりました。

useMemoとuseCallbackが効く条件・効かない条件

2つの違いは単純で、useMemo は関数を実行してその戻り値をキャッシュし、useCallback は関数自体をキャッシュします。どちらも依存配列が同一なら前回の結果を返します。

useMemoが受け付ける計算の条件

react.dev は useMemo に渡す計算関数を「純粋で、引数を取らず、値を返すもの」と定義しています。ここで多い誤用が、非同期処理をメモ化しようとする書き方です。

const visible = useMemo(() => filterTodos(todos, tab), [todos, tab]);  // 同期の計算のみ

const wrong = useMemo(async () => fetchTodos(), []);   // キャッシュされるのは解決値ではなくPromise

実際に useMemo(async () => 1 + 1, []) の戻り値を確認すると [object Promise] でした。async 関数は必ずPromiseを返すため、内部で await してもメモ化される対象はPromiseのままです。値として使うには外側で解決する必要があり、その時点でuseMemoの前提(レンダー中の同期計算)から外れます。

同期の計算なら、同じ配列参照で再レンダーしたとき filterTodos の呼び出しは1回のまま、スプレッド構文で新しい配列を渡すと2回目が走りました。依存に毎回新しいオブジェクトや配列を渡していると、メモ化は形だけになります。

useCallbackが再レンダーを止められる条件

useCallback は単体では何も速くしません。効果が出るのは、受け取る側が memo でラップされたコンポーネントか、依存配列を持つ別のフックである場合だけです。

const Row = memo(function Row({ onSelect }) {
  return <button onClick={() => onSelect(1)}>選択</button>;
});

function List() {
  const [query, setQuery] = useState('');
  const handleSelect = useCallback((id) => { console.log(id); }, []);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Row onSelect={handleSelect} />
    </>
  );
}

この構成で親の入力欄に文字を入れて再レンダーさせても、Row の再レンダー回数は1回のままでした。memouseCallback のどちらか一方でも欠けると、この抑制は成立しません。2つを揃える手間を毎回払うより、次章のReact Compilerを導入して自動化する方が投資対効果は高くなります。

React Compiler 1.0導入後の手動メモ化の要否

2025年10月7日に babel-plugin-react-compiler の 1.0.0 がリリースされ、メモ化の判断はビルド時のコンパイラへ移せるようになりました。react.dev の useMemo / useCallback のページにも、冒頭で自動メモ化の注記が置かれています。

実際に 1.0.0 で、メモ化を一切書いていない次のコンポーネントを変換しました。

function TodoList({ todos, tab }) {
  const visible = todos.filter((t) => t.tab === tab);
  const handleSelect = (id) => console.log(id);
  return <Row items={visible} onSelect={handleSelect} />;
}

出力は react/compiler-runtime_c(7) で7個分のキャッシュ領域を確保する形になりました。tabtodos が変わらなければ filter の結果を再利用し、filter に渡す無名関数(t2)まで tab 単位で別枠にキャッシュしています。propsに依存しない handleSelect_temp としてコンポーネント外へ巻き上げられ、毎レンダーの再生成そのものが消えました。生成されるJSXも visible が同一なら使い回されます。

import { c as _c } from "react/compiler-runtime";
function TodoList(t0) {
  const $ = _c(7);
  const {
    todos,
    tab
  } = t0;
  let t1;
  if ($[0] !== tab || $[1] !== todos) {
    let t2;
    if ($[3] !== tab) {
      t2 = t => t.tab === tab;
      $[3] = tab;
      $[4] = t2;
    } else {
      t2 = $[4];
    }
    t1 = todos.filter(t2);
    $[0] = tab;
    $[1] = todos;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  const visible = t1;
  const handleSelect = _temp;
  let t2;
  if ($[5] !== visible) {
    t2 = <Row items={visible} onSelect={handleSelect} />;
    $[5] = visible;
    $[6] = t2;
  } else {
    t2 = $[6];
  }
  return t2;
}
function _temp(id) {
  return console.log(id);
}

手書きの useMemo / useCallback と同じ効果が、依存配列を書かずに得られるということです。ただし前提があります。コンパイラが安全に変換できるのは、コンポーネントとフックが純粋で、propsやstateを書き換えない場合だけです。eslint-plugin-react-hooks 7.1.1 の推奨設定を入れると purityimmutabilityrefspreserve-manual-memoization が有効になり、既存の手動メモ化を壊さないかも含めて検査されます。導入手順はReact Compilerの自動メモ化の仕組みで扱っています。

判断としては、新規プロジェクトはコンパイラ前提で手動メモ化を書かない、既存プロジェクトはlintを通してから段階導入する進め方を推奨します。ビルド設定を変更できない受託案件など、Babelプラグインを追加できない環境でのみ useMemo / useCallback を手で書く判断が残ります。

useContextとuseReducerによる状態の共有と遷移

状態が増えてくると、更新ロジックの置き場所(useReducer)と配布経路(useContext)という2つの問題が同時に発生します。この2つは組み合わせて使うのが定石です。

useReducerによる状態遷移の集約

更新パターンが3種類を超えたあたりから useState の setter が散らばり始めます。useReducer は状態遷移を1つの純粋関数に集約します。

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added':
      return [...tasks, { id: tasks.length + 1, text: action.text, done: false }];
    case 'toggled':
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case 'deleted':
      return tasks.filter((t) => t.id !== action.id);
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

const [tasks, dispatch] = useReducer(tasksReducer, []);

reducerはReactに依存しない純粋関数なので、コンポーネントを描画せずに単体で呼び出せます。tasksReducer([{ id: 1, text: 'a', done: false }], { type: 'toggled', id: 1 }) を直接実行すると done: true の配列が返り、テストがそのまま書けることを確認しました。default を省くと未知のアクションで戻り値が undefined になり状態が消えるため、例外を投げて気づけるようにしています。状態遷移の複雑さがアプリ全体に及ぶ規模なら、Redux ToolkitのcreateSliceのような専用ライブラリへ移す判断もあります。

Contextによる配布とProvider外の既定値

React 19では <Context.Provider> の代わりに <Context> を直接コンポーネントとして書けます。値と dispatch を別のContextに分けると、値だけを読む子が dispatch の変化で再レンダーされずに済みます。

const TasksContext = createContext([]);
const DispatchContext = createContext(() => {});

function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, []);
  return (
    <TasksContext value={tasks}>
      <DispatchContext value={dispatch}>
        <TaskList />
      </DispatchContext>
    </TasksContext>
  );
}

注意点は、Providerの外側で useContext を呼んでもエラーにならず、createContext の既定値が静かに返ることです。実測でも、包まずに描画したコンポーネントは既定値の空配列(長さ0)を読み取りました。設定漏れに気づけるよう、既定値を null にして読み取り側でエラーを投げるカスタムフックを挟むのが安全です。頻繁に更新される状態をContextで配ると購読側が一斉に再レンダーされるため、その段階ではZustandによる状態管理のような外部ストアが候補になります。

React 19で追加されたフォーム向けフックとuse API

React 19では、フォーム送信の状態管理を担う3つのフックと、Promiseやコンテキストを読む use が加わりました。いずれも <form action={...}> を軸にした設計です。

useActionStateとuseFormStatusによる送信状態の管理

useActionState は、送信処理の結果・フォームに渡すアクション・処理中フラグの3つを返します。送信中フラグを自前の useState で管理する必要がなくなります。

async function submitName(prevState, formData) {
  const name = String(formData.get('name') ?? '');
  if (!name) return '名前を入力してください';
  await save(name);
  return null;                       // エラーなし
}

function NameForm() {
  const [error, formAction, isPending] = useActionState(submitName, null);
  return (
    <form action={formAction}>
      <input name="name" />
      <SubmitButton />
      <p>{error}</p>
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();   // 親フォームの送信状態を子から読む
  return <button type="submit" disabled={pending}>送信</button>;
}

空のまま送信すると error に「名前を入力してください」が入り、値を入れて送信すると保存処理が実行されました。isPending をレンダーごとに記録すると、空送信と有効送信の2回で [false, true, false, true, false]、つまり1回の送信につき false → true → false と遷移します。送信中だけボタンを無効化できるということです。

useFormStatusreact-dom 側のエクスポートで、フォーム自体ではなく子コンポーネントから呼ぶ点に注意してください。名前の似た react-domuseFormStateuseActionState の旧名で、React 19で改名されて非推奨になったものです(useFormStatus とは別物なので、useActionStatereact から import します)。

useOptimisticによる送信前表示と確定時の巻き戻り

useOptimistic は、サーバー応答を待たずに見た目だけ先に更新します。実測では、送信中のリストが こんにちは|追加分(送信中) になり、アクション完了後は元の こんにちは に戻りました。

const [optimistic, addOptimistic] = useOptimistic(
  messages,
  (state, next) => [...state, next + '(送信中)'],
);

この巻き戻りは仕様です。楽観的な値はアクション完了時に破棄され、渡された実データ(この例では messages)で置き換わります。送信成功後に親側の状態やサーバー取得データを更新していなければ、追加した行は消えます。「楽観的更新が一瞬で消える」という報告のほとんどが、この実データ更新の欠落です。

use APIによる条件分岐内でのPromise・Context読み取り

use は他のフックと違い、if文やループの中で呼べます。Promiseを渡すと解決までコンポーネントがサスペンドし、最も近い <Suspense> のフォールバックが表示されます。

function Panel({ show, promise }) {
  if (show) {
    const theme = use(ThemeContext);      // 条件分岐の中で呼んでよい
    const text = use(promise);            // 解決までサスペンドする
    return <p>{theme}:{text}</p>;
  }
  return null;
}

20ms後に解決するPromiseで確認したところ、解決前はSuspenseのフォールバック、解決後は値が描画されました。注意点はPromiseの生成場所です。レンダーのたびに新しいPromiseを作ると解決が永久に終わらないため、キャッシュはフレームワーク側の仕組みかモジュールスコープに置きます。背景はAsync Reactの技術的背景で整理しています。

Effectを書く前に検討すべき代替手段と判断手順

フックの誤用で最も多いのは、useEffect を「値が変わったときに何かする場所」として使うことです。この用途のほとんどはEffectを必要とせず、再レンダーを1往復増やして表示のちらつきを生みます。次の順で判断してください。

  • propsやstateから計算できる値は、レンダー中に計算する(重い場合のみ useMemo
  • ユーザー操作をきっかけにする処理は、イベントハンドラに書く
  • propsが変わったときに state を作り直したい場合は、key を変えてコンポーネントごとリセットする
  • React管理外のストアを購読するなら、useSyncExternalStore を使う

この4つに当てはまらず、かつ「外部システムとの同期」に該当する場合だけがEffectの出番です。機械で検出したいなら eslint-plugin-react-hooks 7.1.1 を導入してください。推奨設定に含まれる set-state-in-effect(error)がEffect内でのstate更新を指摘します。派生stateの生成を検出する no-deriving-state-in-effects はルールとして存在しますが推奨設定には入っていないため、必要なら個別に有効化します。

逆に、Effectを避けるべきでない場面もはっきりしています。WebSocketの接続維持、document.title の書き換え、React管理外の地図・チャートライブラリの初期化と破棄は、Effectとクリーンアップで書くのが正解です。無理にイベントハンドラへ移すと、アンマウント時にリソースが残ります。

よくある質問

useMemoの中で非同期処理は書けますか?

書けますが、期待した結果は得られません。async 関数は必ずPromiseを返すため、キャッシュされるのは解決後の値ではなくPromiseオブジェクトです。非同期データを扱う場合は、useEffect と破棄フラグの組み合わせ、データ取得ライブラリ、または use と Suspense の組み合わせを使ってください。

useRefとuseStateはどう使い分けますか?

その値を画面に表示するかどうかで分けます。表示に使う値は useState、表示しない値は useRef です。タイマーID、直前の値の保存、DOM要素への参照が useRef の典型的な用途です。逆に、入力値やカウンタなど画面に反映すべき値をrefで持つと、値は更新されているのに表示が変わらない状態になります。

useEffectとuseMemoは何が違いますか?

実行タイミングと目的が異なります。useMemo はレンダー中に同期実行され、計算結果をキャッシュして返します。useEffect はレンダー結果が画面に反映された後に実行され、値を返さず外部システムとの同期を担当します。「計算結果が欲しい」なら useMemo、「画面の外に影響を与えたい」なら useEffect と覚えてください。

Reactが再レンダリングされる条件は何ですか?

主な条件は3つです。第一に自身のstateが更新されたとき、第二に親コンポーネントが再レンダーされたとき、第三に購読しているContextの値が変わったときです。propsが変わらなくても親の再レンダーだけで子は再レンダーされるため、これを止めるには memouseCallback の組み合わせが必要になります。なお useState の更新関数に現在と同じ値を渡した場合、Reactは更新をスキップすることがあります。

TypeScriptでuseStateやuseRefの型はどう書きますか?

初期値から推論できる場合は型注釈が不要です。useState(0)numberuseState('')string になります。初期値が null で後から値が入る場合は useState<string | null>(null) のように明示します。DOM参照は useRef<HTMLInputElement>(null)、タイマーIDは環境差を避けるため useRef<ReturnType<typeof setInterval> | null>(null) と書くのが確実です。本記事の掲載コードは型注釈を省いたJavaScript表記ですが、型注釈を加えた版を @types/react 19.2.18 と strict 設定で検査しています。

関連記事

資料請求

RELATED POSTS 関連記事