1. ホーム
  2. React

【React】フックのルール(Rules of Hooks)|条件分岐やループの中で useState を呼んではいけない理由

Share

React を書いていると、必ず一度は「フックはコンポーネントのトップレベルでしか呼べない」というルールにぶつかります。if の中で useState を呼びたくなったり、early return のあとに useEffect を足したくなったりする場面は日常的にありますが、React はそれを許してくれません。この記事では、フックのルール(Rules of Hooks)2つの内容と、なぜそのルールが必要なのかを React の内部の仕組みから解説します。あわせて、実際に出るエラーメッセージの読み方、やってはいけない書き方とその正しい書き換え方、ESLint で機械的に防ぐ設定までまとめます。

フックのルールは2つだけ

覚えるべきルールは2つしかありません。React 公式ドキュメントの「Rules of Hooks」に書かれている内容を要約すると、次のようになります。

ルール内容
トップレベルでのみ呼ぶフックは関数コンポーネントまたはカスタムフックの一番外側で呼ぶ。条件分岐・ループ・ネストした関数・try...catch の中では呼ばない
呼べる場所は2か所だけフックを呼べるのは React の関数コンポーネントの中カスタムフックの中のみ。ただの関数、クラスコンポーネント、イベントハンドラの中では呼べない

ここで言う「トップレベル」は、ファイルの最上位(モジュールスコープ)という意味ではありません。関数コンポーネントの本体の直下、つまり function Form() { のすぐ内側で、ブロックにも関数にも包まれていない位置を指します。逆に言えば、{ } でひとつでも深くネストした場所にフックが書かれていたら、それはルール違反です。

Form.jsx
import { useState, useEffect } from 'react';

function Form({ isEditing }) {
  // ここがトップレベル。フックはこの位置に並べる
  const [name, setName] = useState('');

  if (isEditing) {
    // ここはブロックの中なのでNG
    const [draft, setDraft] = useState('');
  }

  const handleClick = () => {
    // ここは別の関数の中なのでNG
    const [count, setCount] = useState(0);
  };

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

この制約は useState だけの話ではありません。useEffectuseContextuseMemouseRef をはじめ、use で始まる React 組み込みのフックと、自分で書いたカスタムフックのすべてに当てはまります。

なぜトップレベルでしか呼べないのか

「面倒なルールだな」で終わらせず、理由を知っておくと応用が効きます。答えはシンプルで、React はどの useState がどの state に対応するかを「呼ばれた順番」だけで判断しているからです。

React はフックに名前を付けていない

次のコンポーネントを見てください。useState が3回呼ばれていますが、React 側から見ると3つの呼び出しはまったく区別が付きません。変数名 nameemail は JavaScript の分割代入で付けた名前であって、useState には渡っていないからです。

Form.jsx
function Form() {
  const [name, setName] = useState('');      // 1番目のフック
  const [email, setEmail] = useState('');    // 2番目のフック
  const [error, setError] = useState(null);  // 3番目のフック

  // React が知っているのは「1番目・2番目・3番目」という位置だけ
}

React はコンポーネントごとに state の入れ物を用意し、レンダーが始まると「何番目のフックの呼び出しか」を数えながら、その番号に対応する入れ物を返します。実際には配列ではなく連結リストで管理されていますが、考え方は次のコードとまったく同じです。

React の内部で起きていることのイメージ
// コンポーネントごとに持っている state の入れ物
let hooks = [];
// レンダーのたびに 0 に戻される呼び出しカウンタ
let cursor = 0;

function useState(initialValue) {
  const index = cursor;

  // 初回だけ初期値を入れる。2回目以降は保存済みの値を使う
  if (hooks[index] === undefined) {
    hooks[index] = initialValue;
  }

  const setState = (nextValue) => {
    hooks[index] = nextValue;
    rerender();
  };

  cursor++; // 次のフックのために1つ進める
  return [hooks[index], setState];
}

この仕組みが成立するのは、毎回のレンダーでフックが同じ順番・同じ個数だけ呼ばれるという前提があるからです。トップレベルでのみ呼ぶというルールは、その前提を守るための決まりごとにほかなりません。

if で分岐すると、何番目が何にすり替わるのか

実際にずれるところを見てみます。次のコンポーネントは、isLoggedIntrue のときだけ2番目の useState を呼びます。

Form.jsx(壊れる例)
function Form({ isLoggedIn }) {
  const [name, setName] = useState('');       // 常に1番目

  if (isLoggedIn) {
    // isLoggedIn が true のときだけ2番目として呼ばれる
    const [email, setEmail] = useState('');
  }

  // true なら3番目、false なら2番目になってしまう
  const [error, setError] = useState(null);

  // ...
}

初回レンダーで isLoggedIntrue、次のレンダーで false に変わったとき、React 内部の対応付けは次のようにずれます。

呼び出し順1回目のレンダー(true)2回目のレンダー(false)
1番目useState('')nameuseState('')name(一致)
2番目useState('')emailuseState(null)erroremail の入れ物を受け取る)
3番目useState(null)error呼ばれない(入れ物が余る)

2回目のレンダーで error に入るのは、React が2番目の入れ物として保存していた email の値です。useState(null) と初期値に null を書いていても、2回目以降のレンダーでは初期値は無視されるため、メールアドレスの文字列がそのまま error として返ってきます。さらに setError を呼ぶと、更新されるのは email の入れ物です。エラーメッセージのつもりで扱っていた値が実はメールアドレスだった、という追いかけにくいバグになります。

逆に false から true に変わった場合はフックが1つ増えるため、React は「前回より多くのフックが呼ばれた」と判断してエラーを投げます。どちらの方向でも、順番と個数が変わった時点で破綻するわけです。

ルールを破ったときに出るエラー

フックのルール違反は、状況によって出るメッセージが変わります。それぞれ原因の切り分け方が違うので、区別して覚えておくと調査が早くなります。

React has detected a change in the order of Hooks called by …

開発モードで出る警告で、前回のレンダーと今回のレンダーでフックの呼び出し順が変わったことを知らせています。ありがたいことに、React は「何番目で食い違ったか」を表にして出力してくれます。

コンソールに出力される内容
Warning: React has detected a change in the order of Hooks called by Form.
This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks

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

この表の「食い違いが始まった番号」を数えれば、原因のフックがすぐ特定できます。上の例なら、コンポーネントの先頭から2番目に呼ばれているフックのあたりに条件分岐が入っている、と読めます。

Rendered fewer hooks than expected.

前回のレンダーより呼ばれたフックの数が少ないときに投げられるエラーです。メッセージには This may be caused by an accidental early return statement. と続きます。名前のとおり、条件付きの return より後ろにフックが残っているのが典型的な原因です。

これは警告ではなくエラーなので、コンポーネントの描画自体が失敗します。逆にフックが増えた場合は Rendered more hooks than during the previous render. になります。どちらも、条件によって呼ばれたり呼ばれなかったりするフックを探すのが解決の近道です。

Invalid hook call. Hooks can only be called inside of the body of a function component.

2つ目のルール、つまり「呼べる場所」に違反したときのエラーです。React はレンダー中かどうかを内部の状態で判定していて、レンダー処理の外からフックが呼ばれると、この例外を投げます。イベントハンドラの中、setTimeout のコールバックの中、コンポーネントではないただのユーティリティ関数の中などが該当します。

ただしこのエラーには、ルールを守っていても出る別の原因があります。それについては後半で扱います。

やってしまいがちな書き方と、正しい書き換え

ここからは実際のコードで、違反パターンごとの直し方を見ていきます。共通する考え方は「フックは常に呼ぶ。条件はフックの中か、返ってきた値の使い方で分ける」です。

条件分岐の中で呼んでしまう

「有効なときだけ監視したい」といった要件で useEffectif で包むのは、もっともよくある違反です。

SearchBox.jsx(NG)
function SearchBox({ enabled }) {
  const [keyword, setKeyword] = useState('');

  // enabled が切り替わるたびにフックの数が変わってしまう
  if (enabled) {
    useEffect(() => {
      document.title = `検索: ${keyword}`;
    }, [keyword]);
  }

  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

正しくは useEffect を常に呼び、条件判定をコールバックの内側に移します。副作用を実行するかどうかは、フックの中で決めれば十分です。判定に使う値は依存配列にも入れておきます。

SearchBox.jsx(OK)
function SearchBox({ enabled }) {
  const [keyword, setKeyword] = useState('');

  // フックは常に呼ぶ。条件は中で分ける
  useEffect(() => {
    if (!enabled) return;
    document.title = `検索: ${keyword}`;
  }, [enabled, keyword]);

  return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

同じことは useMemo にも言えます。if (items) { const sorted = useMemo(...) } ではなく、useMemo(() => (items ? [...items].sort() : []), [items]) のように、計算の中で分岐させます。フックの呼び出し自体は無条件、中身は条件付き、と覚えてください。

early return より後ろで呼んでしまう

ローディング中やデータが無いときに早めに return するのは読みやすい書き方ですが、その後ろにフックが残っていると Rendered fewer hooks than expected. になります。if と書いていないため見落としやすい違反です。

UserProfile.jsx(NG)
function UserProfile({ user }) {
  if (!user) {
    // user が無いレンダーでは、この下のフックが呼ばれない
    return <p>ユーザーが見つかりませんでした。</p>;
  }

  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    document.title = user.name;
  }, [user.name]);

  return <h1>{user.name}</h1>;
}

直し方は単純で、すべてのフックを return より前に移動するだけです。フック内で user を参照している箇所は、user が無い場合を考慮した書き方に変えておきます。

UserProfile.jsx(OK)
function UserProfile({ user }) {
  // フックはすべて return より前に置く
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (!user) return;
    document.title = user.name;
  }, [user]);

  // 分岐して return するのはフックを呼び終えた後
  if (!user) {
    return <p>ユーザーが見つかりませんでした。</p>;
  }

  return <h1>{user.name}</h1>;
}

「どうしてもフックの呼び出しを分けたい」ときは、コンポーネント自体を分割します。親で条件分岐して、条件を満たすときだけ子コンポーネントを描画すれば、子のフックは「呼ばれるレンダーでは必ず全部呼ばれる」状態になります。コンポーネントがアンマウントされれば state ごと破棄されるので、順番がずれる心配もありません。

ループやコールバックの中で呼んでしまう

配列の要素ごとに開閉状態を持ちたい、といった要件で map() の中に useState を書いてしまうケースです。要素数が変われば当然フックの数も変わるため、これも成立しません。

TodoList.jsx(NG)
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => {
        // コールバックの中でのフック呼び出しはNG
        const [isDone, setIsDone] = useState(false);

        return (
          <li key={todo.id}>
            <button type="button" onClick={() => setIsDone(!isDone)}>
              {isDone ? '完了' : '未完了'}
            </button>
            {todo.title}
          </li>
        );
      })}
    </ul>
  );
}

解決策は1要素分を子コンポーネントに切り出すことです。React は要素ごとに別々のコンポーネントインスタンスを作るので、それぞれが独立した state を持てます。1つのコンポーネントで無理に配列分の state を管理するより、設計としても素直になります。

TodoList.jsx(OK)
function TodoItem({ todo }) {
  // 子コンポーネントのトップレベルなので問題ない
  const [isDone, setIsDone] = useState(false);

  return (
    <li>
      <button type="button" onClick={() => setIsDone(!isDone)}>
        {isDone ? '完了' : '未完了'}
      </button>
      {todo.title}
    </li>
  );
}

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

子に切り出さず親でまとめて持ちたい場合は、フックを増やすのではなくデータ構造を変えるのが定石です。完了済みの ID を useState(() => new Set()) のように1つの state で持てば、要素が何個になってもフックは1つで済みます。

イベントハンドラの中で呼んでしまう

「ボタンが押されたときに state を作りたい」という発想でハンドラ内に useState を書くと、Invalid hook call になります。ハンドラが実行されるのはレンダーが終わったあとで、React はもうフックを受け付けていないからです。

SaveButton.jsx
function SaveButton() {
  // NG: ハンドラの中でフックを呼ぶ
  const handleClickNg = () => {
    const [message, setMessage] = useState('');
    setMessage('保存しました');
  };

  // OK: state はトップレベルで用意し、ハンドラでは更新関数を呼ぶだけ
  const [message, setMessage] = useState('');
  const handleClick = () => {
    setMessage('保存しました');
  };

  return (
    <>
      <button type="button" onClick={handleClick}>保存</button>
      <p>{message}</p>
    </>
  );
}

同じ理由で、useEffectuseMemouseCallback に渡すコールバックの中や、try...catch...finally ブロックの中もフックを呼べる場所ではありません。try の中に書くと例外が発生したときに以降のフックが呼ばれなくなるため、順番が保証できないからです。

ここまでの違反パターンを整理すると、次のようになります。

やりがちな書き方正しい書き換え
if (cond) { useEffect(...) }useEffect は常に呼び、コールバックの先頭で if (!cond) return;
early return の後にフックを書くフックをすべて return より前に移す/条件ごとにコンポーネントを分ける
items.map(() => useState(...))1要素分を子コンポーネントに切り出す/1つの state にまとめる
イベントハンドラ内で useStateトップレベルで宣言し、ハンドラでは更新関数だけを呼ぶ
const value = cond ? useA() : useB()両方のフックを呼んでおき、返り値のほうを三項演算子で選ぶ
クラスコンポーネント内でフックを呼ぶ関数コンポーネントに書き換える/フックを使う子を作って合成する

なお、React 19 で追加された use だけは例外で、条件分岐やループの中から呼ぶことが認められています。Promise や Context を読み取るための API で、他のフックとは違う仕組みで動いているためです。それ以外のフックには、これまでどおりのルールが適用されます。

ルールを守っているのに Invalid hook call が出るとき

Invalid hook call のメッセージには、続けて3つの原因が列挙されています。「React と react-dom のバージョンが食い違っている」「フックのルールに違反している」「同じアプリに React のコピーが複数ある」の3つです。コードを何度見直してもトップレベルで呼んでいるなら、残る2つ、つまり依存関係のほうを疑います。

react と react-dom のバージョンがずれている

フックは react パッケージと react-dom パッケージが内部で連携して動く機能です。react だけを新しくして react-dom が古いままだと、レンダー中かどうかを共有する仕組みがかみ合わず、正しい呼び出しでもエラーになります。package.json でこの2つのバージョンを揃えてください。

React が二重にインストールされている

より厄介なのがこちらです。アプリ本体が読み込む React と、ライブラリが自分の node_modules に持っている React が別物になっていると、フックの状態を持っている React とフックを呼ぶ React が食い違い、やはり Invalid hook call になります。次のコマンドで、React がいくつ入っているかを確認できます。

ターミナル
# インストールされている react の一覧を表示する
npm ls react

# 2つ以上出てくる場合は重複している
# my-app@1.0.0
# ├── react@19.1.0
# └─┬ some-ui-library@2.0.0
#   └── react@18.2.0

特に起きやすいのが、自作のコンポーネントライブラリを npm link やローカルパス指定で読み込んでいるときです。リンク先のライブラリが持つ node_modules/react がそのまま使われるため、確実に二重になります。ライブラリ側では reactdependencies ではなく peerDependencies(と devDependencies)に置き、利用側の React を使ってもらう形にするのが基本の対処です。

package.json(ライブラリ側)
{
  "name": "my-ui-library",
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "react": "^19.1.0"
  }
}

それでも解決しない場合は、バンドラの設定で React の解決先を1か所に固定します。Vite なら resolve.dedupe'react''react-dom' を指定する、webpack なら resolve.alias でアプリ側の node_modules/react を指す、といった方法です。

カスタムフックの中では呼んでよい

2つ目のルールでフックを呼べる場所として挙がっているもう一方が、カスタムフックです。カスタムフックとは、内部で他のフックを呼ぶ普通の関数のことで、複数のコンポーネントで使い回したいロジックをまとめるために書きます。

useWindowWidth.js
import { useState, useEffect } from 'react';

// use で始まる名前にすることでカスタムフックとして扱われる
export function useWindowWidth() {
  // カスタムフックのトップレベルなので呼んでよい
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

注意したいのは、カスタムフックの中でも同じルールが適用される点です。カスタムフックの呼び出しはコンポーネントのフック列にそのまま連結されるので、その中で if や early return を挟んでフックを呼んだり呼ばなかったりすると、結局コンポーネント全体の順番が崩れます。カスタムフック自身も、コンポーネントのトップレベルから条件なしで呼ぶ必要があります。

名前を use で始める理由

React では、関数の名前がその関数の役割を示す約束になっています。先頭が大文字なら関数コンポーネント、use に続けて大文字が来ればフックです。useWindowWidth は該当しますが、usewindowwidthgetWindowWidth は該当しません。

この命名規則が効いてくるのは、主に静的解析の場面です。ESLint の rules-of-hooks ルールは、フックが呼ばれている関数の名前を見て「ここはフックを呼んでよい場所か」を判断します。getWindowWidth という名前の関数の中で useState を呼ぶと、ただの関数だと見なされてエラーとして報告されます。React DevTools がフックの階層を表示できるのも、この規則があるおかげです。

逆に、内部でフックをまったく呼ばない関数には use を付けないほうがよいとされています。名前から「フックのルールに縛られる関数」だと誤解され、本当は条件分岐の中から呼んでよいのに避けられてしまうためです。

ESLint で機械的に防ぐ

フックのルール違反は、目視でのレビューより ESLint に任せたほうが確実です。React 公式が提供している eslint-plugin-react-hooks を入れると、エディタ上で即座に指摘してくれます。

ターミナル
npm install --save-dev eslint-plugin-react-hooks

フラット構成(eslint.config.js)では、プラグインを登録して2つのルールを有効にします。Vite や Next.js の公式テンプレートには最初から含まれていることが多いので、まず既存の設定を確認してみてください。

eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  {
    files: ['**/*.{js,jsx,ts,tsx}'],
    plugins: { 'react-hooks': reactHooks },
    rules: {
      // フックのルール違反を検出する
      'react-hooks/rules-of-hooks': 'error',
      // useEffect などの依存配列の不足を検出する
      'react-hooks/exhaustive-deps': 'warn',
    },
  },
];

2つのルールは役割が違います。rules-of-hooks がこの記事で扱ってきた呼び出し位置のルールを担当し、exhaustive-depsuseEffectuseCallback の依存配列に必要な値が揃っているかを見ます。前者は自動修正できない性質のものなので、指摘されたら記事前半の書き換え方に沿って直します。

exhaustive-deps の警告は、依存配列にコメントを書いて無効化したくなることがありますが、そこで消した依存はほぼ確実に「更新されない値を参照し続けるバグ」になります。警告が出たら無効化ではなく、値の持ち方そのものを見直すのが正しい対処です。

ルールを守ることは、将来的なメリットにもつながります。React Compiler はコンポーネントを自動でメモ化する仕組みですが、その最適化はフックのルールをはじめとする React の規約が守られていることを前提にしています。規約に違反しているコンポーネントは最適化の対象から外されるため、ESLint の指摘を潰しておくこと自体が、そのまま高速化の準備になります。

まとめ

フックのルールは、「関数コンポーネントかカスタムフックのトップレベルでのみ呼ぶ」という2つの決まりに集約されます。React はどの useState がどの state に対応するかを呼ばれた順番だけで管理しているため、条件分岐や early return、ループの中でフックを呼ぶと、レンダーごとに順番や個数が変わり、別のフックの値がすり替わって返ってきます。書き換えの基本は「フックは常に呼び、条件はフックの内側か返り値の使い方で分ける」ことで、要素ごとに state を持ちたい場合は子コンポーネントに切り出します。エラーメッセージは、順番の食い違いなら React has detected a change in the order of Hooks called by …、フックが減ったなら Rendered fewer hooks than expected.、呼べない場所からの呼び出しなら Invalid hook call. と読み分けられます。最後のエラーだけは、react と react-dom のバージョン不一致や React の二重インストールでも発生するため、コードに問題が見当たらなければ npm ls react で依存関係を確認してください。日々の開発では eslint-plugin-react-hooks を有効にして、違反をエディタ上で潰していくのがもっとも確実です。

参考ページ