React Hooks(フック)とは、関数コンポーネントに「状態を持つ」「外部とつなぐ」「計算を覚えておく」といった機能を足すための、use で始まる関数です。 React 19.3.0 の react パッケージには19種、react-dom に1種(useFormStatus)の、合わせて20種の組み込みフックがあります。

この記事では、その20種すべてを 用途・戻り値・再レンダーの有無・よく使う度合い の早見表にまとめ、1つずつ最小コードと実行結果を載せました。React 19 で加わった use・useActionState・useOptimistic・useFormStatus・useEffectEvent も含みます。後半では「値を持ちたいときはどれ?」に答える目的別の選び方表と、フックのルールに反したときのエラー文の再現・直し方を扱います。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / react 19.3.0・react-dom 19.3.0 / jsdom 30.1.1 / esbuild 0.28.2 / TypeScript 7.0.2(開発ビルドの React を jsdom 上で実行)。

React Hooks 全20種の早見表

「再レンダー」の列は、そのフックが再レンダーのきっかけになるか を表します。よく使う度合いは ★3 が「ほぼ毎日使う」、★1 が「必要な場面が限られる」の目安です。

フック 用途(ひとことで) 戻り値 再レンダー 度合い
useState 画面に出す値を持つ [値, 更新関数] 更新関数で起きる ★★★
useReducer 複数の操作がある状態をまとめる [状態, dispatch] dispatch で起きる ★★
useRef 再レンダーしない入れ物・DOM 参照 { current } 起きない ★★★
useContext コンテキストの値を読む コンテキストの値 値が変わると起きる ★★★
useEffect 外部(通信・タイマー・購読)とつなぐ なし 起こさない ★★★
useLayoutEffect 描画前にレイアウトを測る なし 起こさない ★
useInsertionEffect DOM 変更前に CSS を挿入(ライブラリ向け) なし 起こさない ★
useEffectEvent エフェクトから呼ぶ「最新の値を読む関数」を作る 関数 起こさない ★★
useMemo 計算結果を覚えておく 計算結果 起こさない ★★
useCallback 関数を覚えておく 関数 起こさない ★★
useTransition 更新を「待てる更新」として扱う [isPending, startTransition] isPending の変化で起きる ★★
useDeferredValue 値の反映を少し遅らせる 遅れて届く値 遅れた値で追加の描画 ★
useId 一意な ID を作る 文字列 起こさない ★★
useImperativeHandle 親に渡す ref の中身を決める なし 起こさない ★
useSyncExternalStore 外部ストアを購読する スナップショット ストアの変化で起きる ★
useDebugValue 開発者ツールに出すラベルを付ける なし 起こさない ★
use Promise やコンテキストの値を読む 解決した値 Promise の解決・値の変化で起きる ★★
useActionState フォーム送信などアクションの結果を持つ [状態, action, isPending] 送信中・完了で起きる ★★
useOptimistic 完了前に「仮の表示」を出す [仮の値, 追加関数] 追加関数で起きる ★
useFormStatus 親フォームの送信状態を読む(react-dom) { pending, data, method, action } pending の変化で起きる ★★

まず覚えるのは useState・useEffect・useRef・useContext の4つです。 残りは「必要になったときに、この表から選ぶ」で十分です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Hooks一覧と使い分け早見表|React 19対応」

目的別のフックの選び方

やりたいこと 選ぶフック 選ぶ基準
値を持ちたい(画面に出す) useState 値が変わったら画面も変えたい
値を持ちたい(画面に出さない) useRef タイマーの ID、前回の値、クリック回数の控えなど
値を持ちたい(操作が多い) useReducer 「追加・削除・絞り込み」のように更新の種類が複数ある
DOM 要素を触りたい useRef フォーカス、スクロール、サイズの取得
離れたコンポーネントへ値を届けたい useContext または use props の受け渡しが深くなったとき
通信・タイマー・購読をしたい useEffect React の外にあるものと同期する
エフェクトの中で最新の props を読みたい useEffectEvent 依存配列に入れたくない値がある
重い計算をくり返したくない useMemo 計測して遅いとわかった計算
memo した子に関数を渡したい useCallback 子の再レンダーを減らしたい
入力中も画面を固めたくない useTransition / useDeferredValue 更新関数を自分で呼ぶなら前者、値を受け取るだけなら後者
フォーム送信の結果とエラーを持ちたい useActionState 送信→結果表示までを1つにまとめる
送信ボタンを送信中だけ無効にしたい useFormStatus ボタンを別コンポーネントに分けている
押した瞬間に見た目を変えたい useOptimistic いいね、チェックの切り替えなど
Promise の結果を表示したい use と Suspense 読み込み中の表示を Suspense に任せる
label と input を結ぶ ID がほしい useId 同じ部品を何個置いても重ならない ID
外部の状態管理やブラウザ API を購読したい useSyncExternalStore React の外にある「変化する値」

useState・useRef・useReducer の違い

比べる点 useState useRef useReducer
変更すると再レンダー する しない する
値の読み方 count ref.current state
値の変え方 setCount(1) ref.current = 1 dispatch({ type: 'add' })
向いている値 画面に出す単純な値 画面に出さない値・DOM 更新の種類が多い値

動かすための準備(コピペ用)

以下の最小コードは、次の3ファイルを用意すれば手元で同じ結果を再現できます。

npm init -y
npm i react react-dom jsdom esbuild
// setup.mjs:Node.js 上にブラウザ相当の document を用意する
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
// フォーム送信で使う FormData なども jsdom のものにそろえる
for (const k of ['HTMLElement', 'Node', 'Event', 'FormData', 'HTMLFormElement'])
  globalThis[k] = dom.window[k];
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// hooks.tsx の先頭:import と、描画・クリック用の小さな関数
import './setup.mjs';
import {
  act, createContext, Suspense, startTransition, memo,
  use, useActionState, useCallback, useContext, useDebugValue, useDeferredValue,
  useEffect, useEffectEvent, useId, useImperativeHandle, useInsertionEffect,
  useLayoutEffect, useMemo, useOptimistic, useReducer, useRef, useState,
  useSyncExternalStore, useTransition, type Ref,
} from 'react';
import { createRoot } from 'react-dom/client';
import { useFormStatus } from 'react-dom';

const log = (...a: unknown[]) => console.log(...a);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function mount(name: string, ui: React.ReactNode) {
  const el = document.createElement('div');
  document.body.appendChild(el);
  const root = createRoot(el);
  await act(async () => { root.render(ui); });
  log(`[${name}] 初回: ${el.textContent}`);
  const click = async () => {
    await act(async () => { el.querySelector('button')!.click(); });
    log(`[${name}] 操作後: ${el.textContent}`);
  };
  return { el, root, click };
}
npx esbuild hooks.tsx --bundle --platform=node --format=esm --jsx=automatic --packages=external --outfile=hooks.mjs
node hooks.mjs

以降のコードは、この hooks.tsx に続けて書いたものです。各フックの下にある「実行結果」は node hooks.mjs の出力です。

状態を持つフック

useState:画面に出す値を持つ

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>count={count}</button>;
}
{ const m = await mount('useState', <Counter />); await m.click(); }
[useState] 初回: count=0
[useState] 操作後: count=1

useReducer:更新の種類が多い状態をまとめる

type Action = { type: 'add'; text: string } | { type: 'clear' };
function reducer(state: string[], action: Action): string[] {
  switch (action.type) {
    case 'add': return [...state, action.text];
    case 'clear': return [];
  }
}
function Todo() {
  const [items, dispatch] = useReducer(reducer, []);
  return <button onClick={() => dispatch({ type: 'add', text: '備品' })}>items={items.join(',')}</button>;
}
{ const m = await mount('useReducer', <Todo />); await m.click(); }
[useReducer] 初回: items=
[useReducer] 操作後: items=備品

useRef:再レンダーしない入れ物

let refRenders = 0;
function ClickLog() {
  refRenders++;
  const clicks = useRef(0);
  return (
    <button onClick={() => { clicks.current++; log('  ref.current =', clicks.current); }}>
      render={refRenders}
    </button>
  );
}
{ const m = await mount('useRef', <ClickLog />); await m.click(); await m.click(); }
[useRef] 初回: render=1
  ref.current = 1
[useRef] 操作後: render=1
  ref.current = 2
[useRef] 操作後: render=1

ref.current は 1、2 と増えているのに、描画回数は 1 のままです。 これが useState とのいちばんの違いです。

値を届ける・読むフック

useContext:コンテキストの値を読む

const ThemeContext = createContext('light');
function Label() {
  const theme = useContext(ThemeContext);
  return <span>theme={theme}</span>;
}
{ const m = await mount('useContext', <ThemeContext value="dark"><Label /></ThemeContext>);
  await act(async () => m.root.render(<ThemeContext value="light"><Label /></ThemeContext>));
  log('[useContext] value 変更後:', m.el.textContent); }
[useContext] 初回: theme=dark
[useContext] value 変更後: theme=light

React 19 では、コンテキストそのものをタグとして書いて値を渡せます(上のコードの ThemeContext value="dark" の部分)。

use:Promise とコンテキストを読む(React 19)

use は Promise を渡すと解決した値を返し、解決するまでは近くの Suspense の fallback が表示されます。

function User({ p }: { p: Promise<string> }) {
  const name = use(p);
  return <span>name={name}</span>;
}
{ const userPromise = new Promise<string>((r) => setTimeout(() => r('taro'), 50));
  const m = await mount('use(Promise)', <Suspense fallback={<span>loading…</span>}><User p={userPromise} /></Suspense>);
  await act(async () => { await sleep(80); }); log('[use(Promise)] 解決後:', m.el.textContent); }
[use(Promise)] 初回: loading…
[use(Promise)] 解決後: name=taro

コンテキストを渡すと useContext と同じように値を読めます。use はほかのフックと違い、条件分岐や早期リターンのあとでも呼べます。

function ThemeIf({ show }: { show: boolean }) {
  if (!show) return <span>hidden</span>;
  const theme = use(ThemeContext); // use は早期リターンのあとでも呼べる
  return <span>theme={theme}</span>;
}
{ await mount('use(Context)', <ThemeContext value="dark"><ThemeIf show={true} /></ThemeContext>); }
[use(Context)] 初回: theme=dark

外部とつなぐフック(エフェクト)

useEffect:通信・タイマー・購読

function Title({ id }: { id: number }) {
  useEffect(() => {
    log(`  effect: 接続 id=${id}`);
    return () => log(`  cleanup: 切断 id=${id}`);
  }, [id]);
  return <span>id={id}</span>;
}
{ const m = await mount('useEffect', <Title id={1} />);
  await act(async () => m.root.render(<Title id={2} />)); log('[useEffect] id=2 に変更後:', m.el.textContent);
  await act(async () => m.root.unmount()); }
  effect: 接続 id=1
[useEffect] 初回: id=1
  cleanup: 切断 id=1
  effect: 接続 id=2
[useEffect] id=2 に変更後: id=2
  cleanup: 切断 id=2

依存配列の id が変わると、前回の後片付け(cleanup)→ 新しいエフェクト の順で動き、画面から外れるときにも後片付けが動きます。

useLayoutEffect・useInsertionEffect:実行される順番

function Order() {
  useEffect(() => { log('  3. useEffect'); }, []);
  useLayoutEffect(() => { log('  2. useLayoutEffect'); }, []);
  useInsertionEffect(() => { log('  1. useInsertionEffect'); }, []);
  return <span>order</span>;
}
{ await mount('effect の順番', <Order />); }
  1. useInsertionEffect
  2. useLayoutEffect
  3. useEffect
[effect の順番] 初回: order

書いた順番に関係なく、useInsertionEffect → useLayoutEffect → useEffect の順で動きました。ふだんは useEffect を使い、要素のサイズを測って位置を決めるような「描画の前に済ませたい処理」だけ useLayoutEffect にします。useInsertionEffect は CSS を動的に挿入するライブラリのためのフックです。

useEffectEvent:エフェクトから最新の値を読む

function Room({ roomId, theme }: { roomId: string; theme: string }) {
  const onConnected = useEffectEvent(() => { log(`  通知: ${roomId} に接続 (theme=${theme})`); });
  useEffect(() => {
    log(`  effect 実行: ${roomId}`);
    onConnected();
  }, [roomId]); // theme は依存配列に入れなくてよい
  return <span>{roomId}/{theme}</span>;
}
{ const m = await mount('useEffectEvent', <Room roomId="general" theme="light" />);
  await act(async () => m.root.render(<Room roomId="general" theme="dark" />)); log('[useEffectEvent] theme 変更後:', m.el.textContent);
  await act(async () => m.root.render(<Room roomId="random" theme="dark" />)); log('[useEffectEvent] roomId 変更後:', m.el.textContent); }
  effect 実行: general
  通知: general に接続 (theme=light)
[useEffectEvent] 初回: general/light
[useEffectEvent] theme 変更後: general/dark
  effect 実行: random
  通知: random に接続 (theme=dark)
[useEffectEvent] roomId 変更後: random/dark

theme を変えただけではエフェクトは動かず、roomId が変わったときだけ動きます。それでいて、通知の中では 最新の theme(dark) を読めています。「エフェクトを再実行するきっかけにはしたくないが、中では最新の値を使いたい」ときに使います。

useSyncExternalStore:外部ストアを購読する

let online = true;
const listeners = new Set<() => void>();
const store = {
  subscribe(cb: () => void) { listeners.add(cb); return () => listeners.delete(cb); },
  getSnapshot: () => online,
  set(v: boolean) { online = v; listeners.forEach((l) => l()); },
};
function Status() {
  const isOnline = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return <span>{isOnline ? 'online' : 'offline'}</span>;
}
{ const m = await mount('useSyncExternalStore', <Status />);
  await act(async () => store.set(false)); log('[useSyncExternalStore] store 変更後:', m.el.textContent);
  await act(async () => store.set(true)); }
[useSyncExternalStore] 初回: online
[useSyncExternalStore] store 変更後: offline

計算や関数を覚えておくフック

useMemo:計算結果を覚えておく

let calc = 0;
function Total({ prices, label }: { prices: number[]; label: string }) {
  const total = useMemo(() => { calc++; return prices.reduce((a, b) => a + b, 0); }, [prices]);
  return <span>{label}:{total}</span>;
}
{ const prices = [100, 200, 300];
  const m = await mount('useMemo', <Total prices={prices} label="A" />);
  await act(async () => m.root.render(<Total prices={prices} label="B" />));
  log(`[useMemo] 再レンダー後: ${m.el.textContent} / 計算回数=${calc}`); }
[useMemo] 初回: A:600
[useMemo] 再レンダー後: B:600 / 計算回数=1

label が変わって再レンダーされても、prices が同じ配列なので合計の計算は1回だけです。

useCallback:関数を覚えておく

let rowRenders = 0;
const Row = memo(function Row({ onPick }: { onPick: () => void }) {
  rowRenders++;
  return <i onClick={onPick}>row</i>;
});
function List() {
  const [n, setN] = useState(0);
  const onPick = useCallback(() => log('  picked'), []);
  return <><button onClick={() => setN(n + 1)}>n={n}</button><Row onPick={onPick} /></>;
}
{ const m = await mount('useCallback', <List />); await m.click(); await m.click();
  log(`[useCallback] Row のレンダー回数=${rowRenders}`); }
[useCallback] 初回: n=0row
[useCallback] 操作後: n=1row
[useCallback] 操作後: n=2row
[useCallback] Row のレンダー回数=1

親が3回描画されても、memo した Row の描画は1回でした。useCallback は memo した子に関数を渡すとき に効果が出ます。

画面を固めないためのフック

useTransition:更新を「待てる更新」にする

function Tabs() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();
  return (
    <button onClick={() => startTransition(async () => { await sleep(20); startTransition(() => setTab('posts')); })}>
      tab={tab} pending={String(isPending)}
    </button>
  );
}
{ const m = await mount('useTransition', <Tabs />);
  await act(async () => { m.el.querySelector('button')!.click(); }); log('[useTransition] クリック直後:', m.el.textContent);
  await act(async () => { await sleep(40); }); log('[useTransition] 完了後:', m.el.textContent); }
[useTransition] 初回: tab=home pending=false
[useTransition] クリック直後: tab=home pending=true
[useTransition] 完了後: tab=posts pending=false

処理が終わるまで isPending が true になり、元の画面(home)を出したまま待てます。

useDeferredValue:値の反映を遅らせる

function Search({ query }: { query: string }) {
  const deferred = useDeferredValue(query);
  log(`  render: query=${query} deferred=${deferred}`);
  return <span>{deferred}</span>;
}
{ const m = await mount('useDeferredValue', <Search query="a" />);
  await act(async () => m.root.render(<Search query="ab" />)); log('[useDeferredValue] 変更後:', m.el.textContent); }
  render: query=a deferred=a
[useDeferredValue] 初回: a
  render: query=ab deferred=a
  render: query=ab deferred=ab
[useDeferredValue] 変更後: ab

query が変わると、まず以前の値(a)のまま1回描画し、そのあと新しい値(ab)でもう1回描画 しています。重い一覧の描画を、入力欄の更新よりあとに回したいときに使います。

フォームとアクションのフック(React 19)

useActionState:送信の結果を状態として持つ

async function addToCart(prev: { count: number; message: string }, formData: FormData) {
  await sleep(20);
  const name = String(formData.get('item'));
  return { count: prev.count + 1, message: `${name} を追加` };
}
function Cart() {
  const [state, formAction, isPending] = useActionState(addToCart, { count: 0, message: '' });
  return (
    <form action={formAction}>
      <input type="hidden" name="item" value="ノート" />
      <button type="submit">count={state.count} msg={state.message} pending={String(isPending)}</button>
    </form>
  );
}
{ const m = await mount('useActionState', <Cart />);
  await act(async () => { m.el.querySelector('button')!.click(); }); log('[useActionState] 送信直後:', m.el.textContent);
  await act(async () => { await sleep(40); }); log('[useActionState] 完了後:', m.el.textContent); }
[useActionState] 初回: count=0 msg= pending=false
[useActionState] 送信直後: count=0 msg= pending=true
[useActionState] 完了後: count=1 msg=ノート を追加 pending=false

アクション関数は「前回の状態」と「FormData」を受け取り、次の状態を返します。送信中かどうか(isPending)まで1つのフックで取れる のが特徴です。

useFormStatus:親フォームの送信状態を読む

function Submit() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? '送信中…' : '送信'}</button>;
}
function Contact() {
  const [done, setDone] = useState(false);
  async function send() { await sleep(30); setDone(true); }
  return <form action={send}><Submit /><span> done={String(done)}</span></form>;
}
{ const m = await mount('useFormStatus', <Contact />);
  await act(async () => { m.el.querySelector('button')!.click(); }); log('[useFormStatus] 送信直後:', m.el.textContent);
  await act(async () => { await sleep(50); }); log('[useFormStatus] 完了後:', m.el.textContent); }
[useFormStatus] 初回: 送信 done=false
[useFormStatus] 送信直後: 送信中… done=false
[useFormStatus] 完了後: 送信 done=true

useFormStatus は react-dom から import し、form の内側に置いた子コンポーネントの中 で呼びます。上の例でも、ボタンを Submit という別コンポーネントに分けています。

useOptimistic:完了前に仮の表示を出す

function Like() {
  const [likes, setLikes] = useState(10);
  const [optimistic, addOptimistic] = useOptimistic(likes, (cur, n: number) => cur + n);
  async function action() {
    addOptimistic(1);
    await sleep(30);
    setLikes((l) => l + 1);
  }
  return <button onClick={() => startTransition(action)}>likes={optimistic}</button>;
}
{ const m = await mount('useOptimistic', <Like />);
  await act(async () => { m.el.querySelector('button')!.click(); }); log('[useOptimistic] クリック直後:', m.el.textContent);
  await act(async () => { await sleep(50); }); log('[useOptimistic] 完了後:', m.el.textContent); }
[useOptimistic] 初回: likes=10
[useOptimistic] クリック直後: likes=11
[useOptimistic] 完了後: likes=11

30ミリ秒かかる処理が終わる前に、クリック直後から 11 と表示されています。

そのほかのフック

useId:重ならない ID を作る

function Field({ label }: { label: string }) {
  const id = useId();
  return <><label htmlFor={id}>{label}</label><input id={id} /></>;
}
{ const m = await mount('useId', <><Field label="名前" /><Field label="メール" /></>);
  log('[useId] id =', [...m.el.querySelectorAll('input')].map((i) => i.id).join(' , ')); }
[useId] 初回: 名前メール
[useId] id = _r_0_ , _r_1_

useImperativeHandle:親に見せる操作を絞る

React 19 では、関数コンポーネントが ref を props として受け取れます。

type InputHandle = { clear: () => void };
function FancyInput({ ref }: { ref: Ref<InputHandle> }) {
  const inner = useRef<HTMLInputElement>(null);
  useImperativeHandle(ref, () => ({
    clear() { inner.current!.value = ''; },
  }), []);
  return <input ref={inner} defaultValue="abc" />;
}
function Form() {
  const ref = useRef<InputHandle>(null);
  return <><FancyInput ref={ref} /><button onClick={() => ref.current!.clear()}>clear</button></>;
}
{ const m = await mount('useImperativeHandle', <Form />);
  log(`[useImperativeHandle] クリック前 value="${m.el.querySelector('input')!.value}"`);
  await m.click(); log(`[useImperativeHandle] クリック後 value="${m.el.querySelector('input')!.value}"`); }
[useImperativeHandle] 初回: clear
[useImperativeHandle] クリック前 value="abc"
[useImperativeHandle] 操作後: clear
[useImperativeHandle] クリック後 value=""

useDebugValue:カスタムフックにラベルを付ける

function useOnline() {
  const v = useSyncExternalStore(store.subscribe, store.getSnapshot);
  useDebugValue(v ? 'Online' : 'Offline');
  return v;
}
function Badge() { return <span>{useOnline() ? 'on' : 'off'}</span>; }
{ await mount('useDebugValue', <Badge />); }
[useDebugValue] 初回: on

画面の表示は変わりません。公式リファレンスでは「React DevTools がカスタムフックに表示するラベルをカスタマイズできる」フックと説明されています。

フックのルールと、違反したときのエラー文

フックには2つのルールがあります。React 公式の「Rules of Hooks」のページを開いて確認した内容を表にしました。

ルール 内容
トップレベルで呼ぶ 条件分岐・ループ・早期リターンのあと・イベントハンドラ・クラスコンポーネント・useMemo や useReducer や useEffect に渡す関数の中・try/catch/finally の中では呼ばない
React の関数から呼ぶ 関数コンポーネントか、カスタムフック(use で始まる自作の関数)の中で呼ぶ

React は 「何番目に呼ばれたフックか」で状態を対応づけている ため、描画のたびに同じ順番・同じ数で呼ばれる必要があります。ルールに反すると、次のようなエラーになります。

エラー文(先頭) 起きる書き方 直し方
Rendered more hooks than during the previous render. 条件の中でフックを呼び、途中でフックの数が増えた フックを条件の外(上)へ出す
Rendered fewer hooks than expected. This may be caused by an accidental early return statement. 早期リターンのあとにフックがあり、数が減った フックを早期リターンより前へ移す
Invalid hook call. Hooks can only be called inside of the body of a function component. イベントハンドラやふつうの関数の中で呼んだ コンポーネントのトップレベルで呼ぶ

再現1:条件の中で呼ぶ(Rendered more hooks)

function Profile({ loggedIn }: { loggedIn: boolean }) {
  const [name] = useState('taro');
  if (loggedIn) {
    const [visits] = useState(3); // 条件の中
    return <p>{name}:{visits}</p>;
  }
  return <p>guest</p>;
}

loggedIn を false で描画したあと、true に変えて描画したときの出力です。

  console.error: React has detected a change in the order of Hooks called by Profile. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks

       Previous render            Next render
       ------------------------------------------------------
    1. useState                   useState
    2. undefined                  useState
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  例外: Rendered more hooks than during the previous render.

開発ビルドでは、前回と今回のフックの並びを表で見せてくれます。 2番目が undefined から useState に変わったことがわかります。

再現2:早期リターンのあとで呼ぶ(Rendered fewer hooks)

function Detail({ item }: { item: string | null }) {
  const [open] = useState(true);
  if (!item) return <p>none</p>;
  useEffect(() => {}, []); // return のあと
  return <p>{item}:{String(open)}</p>;
}

item="A" で描画したあと、item={null} に変えたときの出力です。

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

再現3:イベントハンドラやふつうの関数で呼ぶ(Invalid hook call)

function Button() {
  const [n, setN] = useState(0);
  function onClick() {
    const [x] = useState(1); // ハンドラの中
    setN(n + x);
  }
  return <button onClick={onClick}>n={n}</button>;
}

function getInitial() { // コンポーネントでもカスタムフックでもない関数
  const [v] = useState(0);
  return v;
}

ボタンをクリックしたとき、また getInitial() をコンポーネントの外で呼んだときの出力です。

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.

メッセージには原因の候補が3つ並びます。上の例は2番(ルール違反)です。コードに心当たりがないときは、1番と3番(react と react-dom の版のずれ、React が2つ入っている)を npm ls react で確かめます。

直したコード

// 1. 条件はフックの「あと」に置く
function Profile({ loggedIn }: { loggedIn: boolean }) {
  const [name] = useState('taro');
  const [visits] = useState(3);
  if (loggedIn) return <p>{name}:{visits}</p>;
  return <p>guest</p>;
}
// 2. フックを早期リターンより前に移す
function Detail({ item }: { item: string | null }) {
  const [open] = useState(true);
  useEffect(() => {}, []);
  if (!item) return <p>none</p>;
  return <p>{item}:{String(open)}</p>;
}
// 3. フックはトップレベルで呼び、ハンドラでは値と更新関数だけを使う
function Button() {
  const [n, setN] = useState(0);
  const [step] = useState(1);
  return <button onClick={() => setN(n + step)}>n={n}</button>;
}

同じ順番(false → true、"A" → null、クリック)で動かした結果です。エラーは出ず、表示が切り替わりました。

表示: guest
表示: taro:3
表示: A:true
表示: none
表示: n=1

公式ページでは、こうした違反を自動で見つける ESLint プラグイン eslint-plugin-react-hooks が紹介されています。

よくある質問

Q. フックは全部覚える必要がありますか? A. まず useState・useEffect・useRef・useContext の4つで、多くの画面を作れます。ほかのフックは「目的別の選び方」の表から、必要になったときに選べば十分です。

Q. use と useContext はどちらを使えばよいですか? A. コンテキストを読むだけなら、どちらでも同じ値が得られます。早期リターンのあとで読みたいときや、Promise も読みたいときは use を選びます。

Q. useMemo と useCallback はすべての計算・関数に付けるべきですか? A. 付けなくても動きは変わりません。計算が重いとわかった箇所や、memo した子へ関数を渡す箇所に絞ると、コードが読みやすく保てます。

Q. カスタムフックは組み込みフックと何が違いますか? A. カスタムフックは、組み込みフックを組み合わせて作る自作の関数です。名前を use で始めること、そして組み込みフックと同じルール(トップレベルで呼ぶ)を守ることが決まりです。

まとめ

  • React 19.3.0 の組み込みフックは20種。 react に19種、react-dom に useFormStatus
  • 最初の4つは useState・useEffect・useRef・useContext。 ほかは目的別の表から選ぶ
  • 値を持つなら、画面に出す→useState、出さない→useRef、操作が多い→useReducer
  • React 19 のフォームまわり は useActionState(結果と送信中)、useFormStatus(子から送信状態)、useOptimistic(仮の表示)
  • use は Promise とコンテキストを読める。 早期リターンのあとでも呼べる点がほかのフックと違う
  • フックはトップレベルで、毎回同じ順番で呼ぶ。 条件の中なら「Rendered more hooks」、早期リターンのあとなら「Rendered fewer hooks」、ハンドラの中なら「Invalid hook call」になる
  • 直し方は「フックを上へ、条件を下へ」。 これだけで3つのエラーはすべて解消できる

参考・出典

  • React 公式リファレンス「Built-in React Hooks」 https://react.dev/reference/react/hooks
  • React 公式リファレンス「Rules of Hooks」 https://react.dev/reference/rules/rules-of-hooks

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Hooks一覧と使い分け早見表|React 19対応」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?