ウェブエンジニア問題集

useCallback — 関数のメモ化と子コンポーネント最適化

useCallback は、依存配列の値が変わらない限り同じ関数オブジェクトを使い回すフックです。 前章の useMemo と仕組みは同じで、メモ化する対象が「計算結果の値」ではなく「関数」に特化しています。

学習者学習者

useMemouseCallback、何が違うの…?どっちも「メモ化」だよね?


useCallbackはuseMemoの特殊ケース

useCallback は「関数を返す useMemo」と全く同じ動きをします。

// この2つは同じ意味
const handleClick = useCallback(() => {
  console.log('clicked');
}, [deps]);
 
const handleClick = useMemo(
  () => () => {
    console.log('clicked');
  },
  [deps],
);

useCallback は「関数をメモ化する」という意図が読み取りやすいので、関数をメモ化したいときはこちらを使います。


なぜ関数のメモ化が必要になるのか

コンポーネント関数が再実行されると、関数内で定義したすべての関数オブジェクトが新しく生成されます。

function Parent() {
  const [count, setCount] = useState(0);
 
  // 再レンダーのたびに、新しい関数オブジェクトが生まれる
  const handleClick = () => {
    console.log('clicked');
  };
 
  return <Child onClick={handleClick} />;
}

関数オブジェクトの生成コスト自体はごく小さいので、これだけならパフォーマンスに影響しません。問題は、渡した先で参照の同一性が重要になる場合です。


React.memoとの組み合わせ

useCallback が最も効果を発揮するのは、React.memo でラップされた子コンポーネントにコールバックを渡すパターンです。

React.memoとは

React.memo は高階コンポーネント(Higher-Order Component)で、propsが前回と同じなら再レンダーをスキップします。

const ExpensiveChild = React.memo(function ExpensiveChild({ onClick }: { onClick: () => void }) {
  // 重い描画処理...
  return <button onClick={onClick}>実行</button>;
});

React.memo はpropsの各値を Object.is(ほぼ ===)で比較します。文字列や数値なら値が同じならスキップされます。しかし関数オブジェクトは、中身が同じでも毎回新しいインスタンスが作られるため、=== 比較で常に false になります。

useCallbackなしの場合

function Parent() {
  const [count, setCount] = useState(0);
 
  // 毎回新しい関数オブジェクト → ExpensiveChildは毎回再レンダー
  const handleClick = () => {
    console.log('clicked');
  };
 
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <ExpensiveChild onClick={handleClick} />
    </div>
  );
}

count が変わるたびに Parent が再レンダーされ、新しい handleClick が生成されます。ExpensiveChildReact.memo で包まれていますが、onClick が毎回新しいので「propsが変わった」と判断し、毎回再レンダーされます。React.memo の意味がなくなっています。

useCallbackありの場合

function Parent() {
  const [count, setCount] = useState(0);
 
  // 依存する値がないので、常に同じ関数オブジェクトが返る
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
 
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <ExpensiveChild onClick={handleClick} />
    </div>
  );
}

useCallback により handleClick の参照が安定するので、ExpensiveChild は「propsが同じ」と判断し、再レンダーをスキップできます。


useCallbackが単体では意味がない理由

学習者学習者

とりあえず関数は全部 useCallback で包んでおけば速くなるんだよね?

先生先生

それが落とし穴。useCallback受け取る側が参照の安定を活かせる時だけ効く。単体だとむしろ無駄なんだ。

重要なポイントとして、useCallback単体ではパフォーマンスに影響しません

function Parent() {
  // useCallbackで関数をメモ化しているが...
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
 
  // React.memoで包まれていない子 → 親が再レンダーされたら常に再レンダー
  return <Child onClick={handleClick} />;
}

ChildReact.memo で包まれていない場合、親の再レンダーに伴って Child も常に再レンダーされます。handleClick の参照が安定していても意味がありません。

つまり useCallback は「参照の安定性を受け取る側が活用できる場合」にのみ効果があります。React.memo された子コンポーネント、useEffect の依存配列、useMemo の依存配列がその「受け取る側」です。


useEffectの依存配列に関数を含める場合

07章で触れたケースの続きです。useEffect の依存配列に関数を入れるとき、その関数が毎回新しく生成されるとエフェクトが毎回再実行されます。

function SearchResults({ query }: { query: string }) {
  // useCallbackなし → fetchResultsは毎レンダーで新しい関数
  const fetchResults = () => {
    return fetch(`/api/search?q=${query}`);
  };
 
  // fetchResultsが毎回変わる → エフェクトも毎回再実行される
  useEffect(() => {
    fetchResults().then(/* ... */);
  }, [fetchResults]);
}

useCallback で関数の参照を安定させることで、エフェクトは query が変わったときだけ再実行されるようになります。

function SearchResults({ query }: { query: string }) {
  const fetchResults = useCallback(() => {
    return fetch(`/api/search?q=${query}`);
  }, [query]); // queryが変わったときだけ新しい関数
 
  useEffect(() => {
    fetchResults().then(/* ... */);
  }, [fetchResults]); // queryが変わったときだけエフェクト再実行
}

ただし07章で述べたように、エフェクト内でしか使わない関数ならエフェクト内で定義する方がシンプルです。useCallback を使うのは、関数をエフェクト以外の場所でも使う場合に限ります。


依存配列にstateを含めるuseCallback

useCallback の依存配列にstateを含める場合、そのstateが頻繁に変わると関数もそのたびに再生成されます。

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
 
  // todosが変わるたびに新しい関数が生成される
  const handleDelete = useCallback(
    (id: string) => {
      setTodos(todos.filter((t) => t.id !== id));
    },
    [todos], // todosに依存
  );
 
  return <TodoList todos={todos} onDelete={handleDelete} />;
}

これだとtodosが変わるたびに handleDelete も新しくなり、React.memo された TodoList のメモ化が効きにくくなります。

updater functionを使えば todos への依存を外せます。

const handleDelete = useCallback((id: string) => {
  setTodos((prev) => prev.filter((t) => t.id !== id));
}, []); // 依存配列が空 → 常に同じ関数

setTodos に関数を渡すことで、Reactが最新の todos を引数として渡してくれます。todos をクロージャ経由で参照する必要がなくなるので、依存配列から外せます。


使うべきか迷ったときの判断基準

使う場面

React.memo された子コンポーネントにコールバックpropsとして渡す関数。これが最も典型的なケースです。

useEffectuseMemo の依存配列に含まれる関数で、参照の安定化が必要な場合。

使わなくてよい場面

React.memo されていない子コンポーネントへのprops。メモ化しても子は結局再レンダーされます。

コンポーネント内で完結する関数(子に渡さない、依存配列にも入れない)。参照の安定性が誰にも利用されません。

JSXに直接書くインラインのイベントハンドラ。

// これにuseCallbackは不要
<button onClick={() => setCount((c) => c + 1)}>+1</button>

まとめ

useCallbackuseMemo の関数版で、依存配列が変わらない限り同じ関数オブジェクトを返します。

単体ではパフォーマンスに影響しません。React.memo された子コンポーネントにpropsとして渡す場合や、useEffect の依存配列に関数を含める場合に、参照の安定性が活きます。

依存配列にstateを含めると、そのstateの変化で関数が再生成されます。updater functionを使えば依存を減らせるケースが多いです。

「このコールバックを受け取る側が、参照の同一性を活用するかどうか」が使うかどうかの判断基準です。