【React】useCallbackで関数の再生成を防ぐ

JavaScript

こんにちは、かつコーチです。

前回は「useMemoで無駄な再計算を防ぐ」を解説しました。

今回はそのペアとも言える useCallback を扱います。

「関数もメモ化できるの?」と思う人も多いと思いますが、useMemo の考え方が分かっていれば理解はそれほど難しくありません。

useCallbackとは?

関数そのものをメモ化する

useCallback は、関数の再生成を防ぐためのフックです。

Reactのコンポーネントは再レンダリングのたびに関数全体が再実行されるため、コンポーネント内で定義した関数も毎回新しく作り直されます。

function SearchBox() {
  // レンダリングのたびに、毎回新しい関数が作られている
  const handleSearch = (keyword: string) => {
    console.log("検索:", keyword);
  };

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

見た目上は同じ処理内容でも、JavaScriptの世界では「レンダリングのたびに作られた別々の関数」として扱われます。

useCallback を使うと、依存配列の中身が変わらない限り、前回と同じ関数(同じ参照)を使い回せます。

import { useCallback, useState } from "react";

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

  const handleSearch = useCallback((keyword: string) => {
    console.log("検索:", keyword);
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>再レンダリング用: {count}</button>
      <input onChange={(e) => handleSearch(e.target.value)} />
    </div>
  );
}

依存配列が [](空配列)なので、count が変わって再レンダリングされても handleSearch は同じ関数のまま保たれます。

useMemoとの関係

実は useCallback(fn, deps) は、useMemo(() => fn, deps) とほぼ同じ意味です。

useMemo が「計算結果」をメモ化するのに対して、useCallback は「関数そのもの」をメモ化する、という役割分担だと考えると整理しやすいです。

なぜ関数の再生成が問題になるのか

memoでラップした子コンポーネントが効かなくなる

関数の再生成が本当に問題になるのは、その関数を memo でラップした子コンポーネントにpropsとして渡しているときです。

import { memo, useState } from "react";

const SearchButton = memo(function SearchButton({
  onSearch,
}: {
  onSearch: () => void;
}) {
  console.log("SearchButtonが再レンダリングされました");
  return <button onClick={onSearch}>検索</button>;
});

function SearchScreen() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState("");

  // レンダリングのたびに新しい関数が作られてしまう
  const handleSearch = () => {
    console.log("検索:", keyword);
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>カウント: {count}</button>
      <SearchButton onSearch={handleSearch} />
    </div>
  );
}

SearchButtonmemo でラップされているので、本来はpropsが変わらない限り再レンダリングされないはずです。

ところが handleSearch はレンダリングのたびに新しく作られる関数なので、memo から見ると「propsが変わった」と判定され、毎回再レンダリングが発生してしまいます。

つまずきやすいポイント:useCallbackを付けたのに効果が出ない

かつコーチが実際にハマった例

私が最初に useCallback を使ったとき、「付ければ何でも速くなる」と思い込んで、あちこちに機械的に追加していた時期がありました。

❌ Before:memo化していないコンポーネントにuseCallbackを使う

import { useCallback, useState } from "react";

// memoでラップされていない、普通の関数コンポーネント
function SearchButton({ onSearch }: { onSearch: () => void }) {
  return <button onClick={onSearch}>検索</button>;
}

function SearchScreen() {
  const [keyword, setKeyword] = useState("");

  const handleSearch = useCallback(() => {
    console.log("検索:", keyword);
  }, [keyword]);

  return <SearchButton onSearch={handleSearch} />;
}

SearchButtonmemo でラップされていないため、親が再レンダリングされれば SearchButton もどのみち再レンダリングされます。

つまり、handleSearchuseCallback でメモ化しても、SearchButton 側の再レンダリングは1回も防げていない、ということに後から気づきました。

useCallback を付けること自体にも依存配列の比較コストがかかるため、効果のない場所に付けると純粋にコードが複雑になるだけの結果になります。

✅ After:memo化した子コンポーネントとセットで使う

import { memo, useCallback, useState } from "react";

const SearchButton = memo(function SearchButton({
  onSearch,
}: {
  onSearch: () => void;
}) {
  return <button onClick={onSearch}>検索</button>;
});

function SearchScreen() {
  const [keyword, setKeyword] = useState("");

  const handleSearch = useCallback(() => {
    console.log("検索:", keyword);
  }, [keyword]);

  return <SearchButton onSearch={handleSearch} />;
}

子コンポーネントを memo でラップして初めて、useCallback による関数の参照安定化が意味を持つようになります。

「memo化した子コンポーネントに渡す関数にだけuseCallbackを使う」、これが基本方針だと理解してからは、無駄な最適化に時間を使わなくなりました。

応用:カスタムフックの戻り値を安定させる

useEffectの依存配列で無限ループを防ぐ

useCallback は、カスタムフックが返す関数を安定させ、useEffect の依存配列に含めても無限ループが起きないようにする場面でも活躍します。

import { useCallback, useEffect, useState } from "react";

function useSearch(initialKeyword: string) {
  const [keyword, setKeyword] = useState(initialKeyword);

  const search = useCallback(async () => {
    console.log("検索実行:", keyword);
  }, [keyword]);

  return { keyword, setKeyword, search };
}

function SearchPage() {
  const { keyword, setKeyword, search } = useSearch("");

  useEffect(() => {
    search();
  }, [search]);

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

searchuseCallback でメモ化していないと、SearchPage が再レンダリングされるたびに新しい search 関数が作られ、それを依存配列に含めた useEffect が毎回発火し続ける無限ループに陥ります。

カスタムフックから関数を返すときは、useCallback でメモ化しておくのが定石です。

まとめ

この記事のポイント

  • useCallback は関数そのものをレンダリングをまたいでメモ化するフック
  • 効果を発揮するのは、memoでラップした子コンポーネントに関数を渡すとき
  • memo化していないコンポーネントに使っても、再レンダリングは防げない
  • カスタムフックが返す関数をメモ化すると、useEffectの無限ループ防止にも役立つ

次に読むべき記事

次回は「カスタムフックを自作してロジックを再利用する」です。

今回登場したuseSearchのようなカスタムフックの作り方を、体系的に解説していきます。

→ 次の記事:カスタムフックを自作してロジックを再利用する

タイトルとURLをコピーしました