uhyo/blog

useCallbackはとにかく使え! 特にカスタムフックでは

2021年2月23日 公開

Reactには、パフォーマンス最適化のためのAPIがいくつかあります。具体的にはReact.memouseMemo、そしてuseCallbackです。 React.memoで囲まれた関数コンポーネントは、propsが以前と変わっていない場合に再レンダリングが抑制されます。 また、useMemouseCallbackは、関数コンポーネント内での値の再計算を抑制する効果を持ちます。

これらは最適化のためのツールなので、「過度な最適化」を避けるように啓蒙する言説がよく見られます。 すなわち、ちゃんと本当に最適化のために必要なところにだけこれらを使おうということです。 特に、React.memoはpropsが以前と変わっているかどうかを判定するためのオーバーヘッドがあるし、useMemouseCallbackもフック呼び出しのオーバーヘッドがあります。 意味がないところでReact.memoを使うと、オーバーヘッドによりむしろ悪影響があるかもしれません。

……とは言っても、実際に無駄なReact.memoの使用が悪影響になったという報告がデータ付きで上がっているのは、筆者は寡聞にして見たことがありません。 正直なところ、余計なuseMemouseCallbackが実際的なパフォーマンスに与える影響は基本的に無視できる程度であり、それ以外の論点から考察するのが筋が良いと思っています。 そこで、この記事ではuseCallbackとカスタムフックに焦点を当て、「設計」の観点から考察します。

結論は、カスタムフックが関数を返すなら常にuseCallbackで囲めです。

useCallbackの意味

まず、そもそもuseCallbackはどのような恩恵をもたらすのか簡単に説明します。 useCallbackuseMemoの亜種で、関数に特化しています。 例えばこのように使えます。

const App: React.VFC = () => {
  const handleClick = useCallback((e: React.MouseEvent) => {
    console.log("clicked!");
  }, []);

  return (
    <button onClick={handleClick}>button</button>
  );
};

useCallbackは、初回の呼び出し(Appの初回のレンダリング)では渡された関数をそのまま返します。 よって、handleClickconsole.log("clicked!");を実行する関数となります。 Appが再レンダリングされたとき、useCallbackの返り値としては初回レンダリング時のときの関数オブジェクトが再利用されます(useCallbackに渡された関数オブジェクトは今回は捨てられます)。 つまり、handleClickは初回のレンダリング時も2回目のレンダリング時も同じ(===の意味で等しい)関数オブジェクトになります1useCallbackを噛まさない場合は、handleClickは毎回新しく作られた関数オブジェクトとなるでしょう。

実は上の例の場合、useCallbackは特にパフォーマンス上の意味はありません。 useCallbackが効いてくる典型的なケースは、useCallbackの返り値の関数がReact.memoが適用されたコンポーネントに渡されるような場合です2

const App: React.VFC = () => {
  const handleClick = useCallback((e: React.MouseEvent) => {
    console.log("clicked!");
  }, []);

  return (
    // 再レンダリング回避!
    <SuperHeavyButton onClick={handleClick} />
  );

このように、描画が重いのでReact.memoで囲まれたSuperHeavyButtonがあるとしましょう。 この場合、Appが再レンダリングされてもSuperHeavyButtonは再レンダリングされないでしょう。 なぜなら、2回目のレンダリングでもuseCallbackの効果により、handleClickは1回目と同じ(===)関数オブジェクトとなります。 よって、react.memoによってSuperHeavyButtonの再レンダリングが抑制されます。 もしuseCallbackが無かったら、Appの再描画のたびにSuperHeavyButtonも再描画されるでしょう。

const App: React.VFC = () => {
  const handleClick = (e: React.MouseEvent) => {
    console.log("clicked!");
  };

  return (
    // 毎回再レンダリングされる……
    <SuperHeavyButton onClick={handleClick} />
  );

逆の見方をすれば、useCallbackの使用に常に意味があるわけではないということです。 このように、React.memoで囲われたコンポーネントに関数を渡すような場合でなければuseCallbackが無駄ということになります。 useMemouseCallbackの使用に慎重になる人はこのような無駄を気にしているのでしょう。

カスタムフックとuseCallback

次に、カスタムフックに目を向けてみましょう。 カスタムフックとは、フックの呼びだしを含むロジックをまとめた関数であり、Reactに組み込みのフックと同様にuseで始まる名前を付ける慣習があります。 カスタムフックはuseStateuseEffectのようなReactと結びついたロジックを再利用可能な形で提供できるのが主な良い点です。

例えば次のような実装のuseToggleを考えてみましょう。

function useToggle(initialState: boolean): [boolean, () => void] {
  const [state, setState] = useState(initialState);

  const toggle = () => {
    setState(b => !b);
  };
  return [state, toggle];
}

これは、(useStateを内部で呼び出していることからも分かるように)ステートを内包するフックで、そのステートはboolean型に固定されています。 ステートそのもののほかにtoggle関数を返しており、この関数はステートの真偽を反転させる関数です。

このuseToggleuseStateと似たような感じで次のように使えるでしょう。

const App: React.VFC = () => {
  const [on, toggle] = useToggle(false);

  return (
    <SuperHeavyButton onClick={toggle}>
      {on ? "ON" : "OFF"}
    </SuperHeavyButton>
  );
};

useToggleのカスタムフックとしての意義は、より幅広い使い方が可能なuseStateを内包しつつ、特定の使い方にフォーカスすることによってより単純なインターフェース(引数なしで呼び出せるtoggle関数)を提供している点にあります。

ここで特に注目すべきは、useToggleが関数toggleを返しているという点です。 わざとらしく上のコード例でtoggleSuperHeavyButtonに渡していますが、useToggle内でこの関数が毎回新規に生成されているため、SuperHeavyButtonが毎回再レンダリングされてしまいます。

この問題は、useToggle内でuseCallbackを使うことで回避できます3

function useToggle(initialState: boolean): [boolean, () => void] {
  const [state, setState] = useState(initialState);

  const toggle = useCallback(() => {
    setState(b => !b);
  }, []);
  return [state, toggle];
}

そうなると、ここで問題が生じます。 useToggleの中でuseCallbackを最初から使うべきなのか、それとも必要に駆られてからuseCallbackを付け足すべきなのかです。

筆者の答えは、最初からuseCallbackを使えです。

カスタムフックと責務の分離

カスタムフックを作る理由は、普通の関数を作る理由と全く同じであり、すなわち責務の分離とかカプセル化です。 一度カスタムフックとして分離された以上、インターフェースの内側のことはカスタムフック内で完結すべきです。 カスタムフックを使う側はカスタムフックの内側のことを知るべきではなく、その逆も然りです。

つまり、useToggleが返すtoggle関数が毎回変わる(=使う側に再レンダリングを強制する)のか、それともuseCallbackで囲まれていて基本的に変わらない(=使う側は再レンダリングを抑制できる)のかは、useToggleの仕様の一部としてuseToggle側が決めることです。

もしも「useToggleの返り値が毎回変わっていてSuperHeavyButtonが再レンダリングされてしまい困るからuseToggleuseCallbackを追加した」というようなことが起こった場合は、それはuseToggleを使う側の都合を鑑みてuseToggleを仕様変更したということになります。 つまり、useToggleをコンポーネントロジックから分離して再利用可能にしたつもりが、結局使う側に振り回されてしまい再利用可能になっていなかったということです。 ご存知の通り、再利用可能性の低いものを無理に共通化し、そこにそれを使う側の都合を押し込んでいった場合、最終的にできるのはただのおいしいスパゲッティです。 せっかくカスタムフックを作るのだから、再利用可能性と独立性が高いものにするべきです。

簡単な言葉で言い直せば、結局のところ「返り値の関数はuseCallbackで囲んだほうがカスタムフックの汎用性が高くなるからそうしろ」ということです。 場合によってはそのuseCallbackが無駄になるかもしれませんが、観測できるかどうかも分からないオーバーヘッドよりは設計上の要請のほうを優先したいというのが筆者の考えです。

また、カスタムフックのインターフェース上の意味を考えてみても、useCallbackを使う方が妥当である場合が多いでしょう。 例えば、Reactに組み込みのuseStateが返す関数(ステート更新関数)は毎回同じ関数であることが保証されています。 その理由はこれまで述べてきた汎用性に係る要請に加えて、ステート更新関数は常に同じ処理をする関数である(状況によって処理内容が変わるものではない)ことも寄与していると考えられます。

そもそもReactの世界では、「値が違う」(===ではない)ことが色々な処理のトリガーになります。 React.memoもそうですし、useStateやコンテキストなども“違う”値が入ることが再レンダリングを引き起こします。 ですから、違わないものは違わないと明確にする(===になるようにする)ことには、単なるパフォーマンス最適化だけではなくロジック上の意味が付与されます。 毎回違うものを返すということは、本当に毎回意味が異なるものを返していると受け取られます。 少なくとも、返されたものを使う側はそのように扱わなければいけません。 そうしないと、useMemoの依存リストを間違えて厄介なバグを生み出すことにも繋がりかねないからです。

useCallbackは絶対的な保証ではなく、同じものを返せる場面で違うものを返してしまうこともあるとされていますが、それでも多くの場合きちんとメモ化が働き同じものを返してくれます。 それはそのまま、useToggleが必要のない限り同じ関数を返すというロジック上意味のある挙動に繋がっているのです。 同じ意味のものを返すならば、useCallbackの力を借りてきちんと同じものを返しましょう。

まとめ

この記事では、カスタムフックから関数を返す場合にuseCallbackを使うべき理由を説明しました。 カスタムフックは再利用性のために作られるものなので、より高い再利用性のためにはuseCallbackが必要です。 また、Reactでは値が同じであることにロジック上の意味が与えられるので、同じ意味の関数を返すときは極力オブジェクトとして同じ関数を返すべきです。


  1. Reactでは、かならず毎回同じ関数オブジェクトになることが保証されているわけではなく、場合によってはメモ化されないかもしれないとされています。
  2. 他の場合としては、関数がuseEffectuseMemoなどの依存リストに入る場合があります。
  3. 次の例でuseCallbackに渡す依存配列は[]としていますが、useStateが返すsetStateは常に同じ関数オブジェクトであることが保証されているのでこれで問題ありません。