React HooksのuseCallbackを正しく理解する


はじめに

React HooksのuseCallbackは、イベントハンドラ(コールバック)を使うときは無条件に使うものだと思っていませんか。実際にはコールバックが「ある条件をみたす」ときに使うべきもののようです。このある条件を整理して明確にするために記事を書きました。

useCallbackとは何か

useCallbackは、useMemoのような、重い計算を回避しキャッシュを使うというような効率向上のための仕組みではありません。useCallbackに渡す関数内で行なう計算の重さはまったく無関係です。
useCallbackがやることは、「コールバック関数の不変値化」です。

「関数を決定する処理のメモ化」と言えるかもしれません。アロー式は原理的に常に新規関数オブジェクトを作ってしまいますが、useCallbackは「意味的に同じ関数」が返るかどうかを判別して、同じ値を返す関数が返るべきなら新規のアロー式を捨てて、前に渡した同じ結果を返す関数を使い回しで返します。

同じ値を返す関数なのに、異なる実体関数をpropsに渡すと無駄なrenderが走ってしまうので、「一回useCallbackをくぐらせる」ことで一意化し、引き起こされるかもしれない無駄なレンダリングを抑制することができます。

関数が純粋であれば、引数が同じなら常に同じ値を返すはずだから毎回「くぐらせる」必要なんかなくて、保存していた前の値を常にかえせばいいのでは、と思うかもしれませんが、関数がクロージャとして変数をキャプチャしているとそうはいかず、「同じ引数なのに異なる値を返す」ということがありうるのです。クロージャがキャプチャしている変数は、追加的な暗黙の引数なのです。なのでキャプチャしていて、結果に影響を及ぼす変数の値を考慮するために、それらを依存変数として渡して判別を行います。

コールバック関数の不変値化とは何か

ローカル関数がローカル変数などをキャプチャするというのは実質的にはそのローカル関数の引数なのですが、useCallbackがやってくれることは、ローカル関数と、そのローカル関数がキャプチャする変数列を依存変数として与えて呼び出すと、依存引数の値と関数本体をあわせてinternし、不変値化することです。Immutable.jsの提供する不変データと同じように、その結果を使うことでpropsとして関数を使う場合のシャロー比較がうまくいきます。

その結果として、その渡したコンポーネントが以下の条件を満す場合、不要な画面更新が減ります。

  • 関数コンポーネントでReact.memo()されている場合
  • クラスコンポーネントでshouldComponentUpdate()でpropsをシャロー比較して同じならスキップしている場合
  • クラスコンポーネントでPureComponentから継承した場合
  • react-reduxのconnectを使ったSmartコンポーネント

基本の整理

useCallback Hookは以下の形で呼び出します。

const callback = useCallback(関数, [deps])

ここで関数にアロー式をあたえたとします。

const Component = () => {
  const callback = useCallback(()=>{処理}, [deps])
    :
  return <Hoge onHoge={callback} />
}

JavaScriptの言語仕様上、renderごとに常にアロー関数の生成式「()=>{処理}」が評価されて新たな関数オブジェクトがつくられます(アロー関数のボディは評価されるわけではない)。アロー式が関数引数である場合、たとえばuseCallbackの引数である場合にしても同じです。引数として評価されたときに、常に新しく関数オブジェクトが作られます。useCallback側で処理の選択肢があるのは、その関数値を捨てるか、次の呼び出しで返すまえにとっとくかだけです。

なので、このComponentのrender時に毎回関数オブジェクトを作らせないためには、関数本体の外側に移動して

const handlerFunc = ()=>{処理}
const Component = () => {
    :
  return <Hoge onHoge={handlerFunc} />
}

とするしかありません。こうすると、

  • 利点1. 関数オブジェクトの生成を抑制する
  • 利点2. Hogeが「propsがシャロー比較の意味で変化しなければrenderを呼ばない」という最適化されたコンポーネント(Rect.memo化されたコンポーネントやPureComponent)であったときに効率が良い

という2点で効率が高いです。

何が問題か

問題は、イベントハンドラを関数コンポーネントの外側に外出しできるのは、そのハンドラ本体の処理が、propsなどの関数引数(=ローカルスコープ)や、useStateなどで得られるローカル変数としてのstateを使用しないで実行できる場合のみだということです。それらへのアクセスが必要な場合は非常に多いので、無視することはできません。

なので、ローカル関数にする場合が多くなるでしょう。しかし、ローカル関数は、render時点でのローカル変数をキャプチャしてしまう1 ので、へたに保存して使いまわそうとすると、一般に非同期に呼び出されるイベントハンドラでは、そのキャプチャ元の変数の最新の値を使って処理できない、という問題があります。使いまわさなければいいんですけどね。使いまわしたくなる事情もあるのです。

問題回避策

問題回避策1

先ほど、ローカル関数がローカル変数をキャプチャしてしまうことが問題の一環だと述べました。useStateの返り値もローカル変数であり、この問題をかかえます。しかしuseStateを上手く使うことでイベントハンドラでのキャプチャを回避できる場合があります。

この状況を説明します。
useState Hooksの返り値の配列(タプル)第二要素のいわゆるsetter関数が得られます。たとえば、

const Component = () => {
  const [counter, setCounter] = useState(0);
    :
  return <Hoge onHoge={setCounter} />
}

ここでのsetCounterがsetter関数です。setter関数には通常は新しいstateの値を渡しますが、値ではなく関数を渡すこともでき、たとえば以下のように使えます。

setCounter((oldCounter)=>(oldCounter + 1)) // counterを1増加させる。
// setCounter(counter + 1)と同じだがcounterをキャプチャしない。

こちらを使ってstateをローカル関数経由ではなくsetStateの引数からもらうようにすれば、ローカル変数としてのcounterはキャプチャしないので、「古い値」問題はおきません。
上の場合、setCounter自体もuseStateの返り値なのでローカル変数じゃないかと思うかもしれませんが、Hooksのしくみ的にはsetCounter自体はコンポーネントごとに一意で、複数回のuseStateでも同じ関数が得られますのでキャプチャ問題は起きません。

問題回避策2

useStateが返すsetCounter関数はローカルスコープにある変数や引数(具体的にはprops引数)に依存しないので、useCallbackに包まずにHogeのonHogeに直接わたしても効率は劣化しませんし問題も起きません。そのsetterを渡した側で、回避策1の方式でstateの値を参照することもできます。

もっともこれは個々の変数レベルのsetter操作なので、「いくつかの変数に対するロジック、操作」を、自コンポーネントでまとめてイベントハンドラに渡すためには関数にする必要があり、コールバック関数を定義する必要がでてきます。

問題回避策3

useReducerを使い、イベントハンドラ用途ではdispatchをprops経由で渡します。

問題回避策4

react-reduxを使い、イベントハンドラをpropsとしてはバケツリレーせず、イベントを検出する直下のコンポーネントでuseDispatchしてアクションをdispachさせます。
これが個人的には妥当だとおもってます。react-redux 7.1のhooksベースreduxは別ものです。

useCallbackを使うかどうかの判断

さて、いずれの回避策も適用できない、もしくはしたくないとして、いよいよuseCallbackを使うかどうかの判断しなければなりません。

ケース1「利点1」を得たい場合(自動的に「利点2」も得られる)

「利点1」のために、不変のイベントハンドラを定義することが必要であり、state, props, ローカル変数を直接参照する必要がないなら以下が可能です。

const handleHoge = () => {propsやstateを使わない処理}
const Component = (props) => {
  return <Hoge onHoge={handleHoge} />
}

ケース2-1「利点1」をあきらめるが、「利点2」を得たい場合

state, props, ローカル変数を参照する必要があり、かつその変更にもかかわらず同じ関数を得たいなら、それらを間接的に参照するように関数を作りこむことになります。ある意味、クラスコンポーネントのthisをエミュレーションし、それを通じてpropsアクセスするようにします。

具体的には、useRefなどを使ってstateやpropsの値をコピーしておき、それをつかった処理をする関数を保存して渡します。propsなどをrender時に毎回そのrefに保存するようにすればよいわけです。

useCallbackの依存変数指定にたよらず(指定を間違えるかもしれない! 依存変数はあとで増えるかもしれない!)、propsのみを信じ、そこを直接使います。

// 試してない
const Component = (props) => {
  const ref = useRef(props);
  useEffect(() => {
    ref.current = props;
  });
  const handleHoge = useCallback(()=>{
    const props = ref.current;
    // propsを使った処理 
  }, []);
  return <Hoge onHoge={handleHoge} />
}

(やったことないので本当にできるか不明)
propsの変更を適当にスロットルして、依存変数を制御するとかもできるかもれない。
(追記:コメントでうまくいったとご報告をいただきました。またアドバイスいただき[ref]を依存変数から除去しました。)

ケース2-2「利点1」をあきらめるが、「利点2」は得られる場合その2

依存変数がなければ、useCallbackは常に前回と同じ値もしくは初期値を返します。

const Component = (props) => {
  const handleHoge = useCallback(() => {propsやstateを使わない処理}, []);
  return <Hoge onHoge={handleHoge} />
}

ケース1と比べると、利点2は同等です。利点1は得られませんが、たいてい無視できるでしょう。

ケース2-3「利点1」をあきらめるが、「利点2」を一部得たい場合

useCallbackに、必要な依存をつけてローカル関数をくぐらせたものを使ってください。
useCallbackの使いかたの本道です。

const Component = (props) => {
  const [state, setState] = useState(..);
  const handleHoge = useCallback(
     () => {propsやstateを使った処理},
     [props.x, state]
  );
  return <Hoge onHoge={handleHoge} />
}

「利点2」を一部得たい場合」の一部というのは、依存変数が変化しなければ一応、再renderはおきないからです。しかし、クラスコンポーネントの場合に、コンストラクタでイベントハンドラを.bind(this)して保存しなおしたり、クラスフィールドで保存した場合は関数の更新は一切起きなかったわけなので、一部はあきらめていることになります。これは関数コンポーネントではthis経由でpropsとstateがアクセスできないことに起因する問題です。

ケース3「利点1」と「利点2」の両方をあきらめる場合。

ローカルなアロー関数をそのままわたしてください。useCallbackはdepsが漏れるとバグになりやすいので、良い選択かもしれません。

利点2がそもそも得られないパターン

上のケース2のいずれかを採用して、利点2が得られるとおもってuseCallbackを使うとしても、実際には利点2が得られていない場合があります。この場合ケース3にフォールダウンした方がよいかもしれません(統一性のためケース2-2,2-3を採用するという判断もありえますが、前述のようにバグ注意です)

propsの変化の有無が効率に無関係な場合

デフォルトでは、render()呼び出しはpropsの変化にかかわらず常に行なわれます。なので利点2を考えてケース2にする意味がない場合があります。propsをシャロー比較してrenderを最適化しようとしているケースは以下が考えられます。

  • React.memoでメモ化されているケース
  • PureComponent
  • componentWillUpdateでシャロー比較の更新制御などをしているコンポーネント
  • react-reduxのconnectを使ったSmartコンポーネント
    • redux暗黙の処理として、propsのシャロー比較での更新制御が入っているので、ケース2-1,2-2のuseCallbackで「利点2」が得られる可能性があります。
    • ただし、reduxのdumbコンポーネントやreact-redux 7.1移行のHooks系でstoreと結びつけている場合はこの限りではありません。

html要素にコールバックを渡す場合

たとえばbuttonはReact.memo化されていないので(要出典)、利点2が得られません。

<button onClick={()=>{}} >

で充分です。

まとめ

まとめると、useCallbackを使うべきなのは、イベントハンドラをローカル関数にせざるを得ない場合、つまり関数コンポーネント本体のスコープ内の引数や、ローカル変数、特にpropの引数や他のuseStateを始めとするhooks呼び出しで得られるローカル変数の値に、関数の処理が依存している場合であり、かつ、そのローカル変数をイベントハンドラとして渡そうとしているReact要素がReact.memoされている場合です。

おわり

hooksは最高なんですが、留意点がありますので気をつけて使っていきましょう。