React hooksで再レンダリングを防ぐためにイベントハンドラをメモ化するとハンドラの外側にある変数参照値もメモされてしまう問題について

タイトルが長い!

3行

  • useCallback(()=> ,[])とかuseEffect(()=>,[])みたいにハンドラをメモる
  • イベントハンドラ内で外部変数(例えばconst [state, setState] = useState()state)を参照する
  • イベントが起こるたびにハンドラで参照しているstateは常に最新のものが来て欲しいのだが………
    • こない

実例を書いた。

このデモは

  • カウンターアプリ
    • increment
    • decrement
    • トータルクリック回数もわかる

というもの。

『To use useCallback』と『To use useEventCallback』の2つがあって

  • 『To use useCallback』
    • 動きそうで動かない例
    • 記事タイトルの現象が起こっている
  • 『To use useEventCallback』
    • 動くようにしたほうの例
    • 記事タイトルの問題を力技で解決している

という内訳です。

とりあえずのところ『To use useCallback』のほうだけいまはみてほしい。

codesandbox.io

『To use useCallback』はタイトルの現象が起こって動かないのが正常

kwsk

export function ToUseUseCallback({ ...rest }) {
  const [count, setCount] = useState(0)
  const [clickCount, setClickCount] = useState(0)

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
    setClickCount(clickCount + 1) // ← ここ!
  }, [])

  const onDecrement = useCallback(() => {
    setCount(count => count - 1)
    setClickCount(clickCount + 1) // ← ここ!
  }, [])
.
.
.

setClickCount(prevCount => prevCount + 1) とも書けるけどそう書くと問題なく動いてしまう…なので例のためにわざとsetClickCount(clickCount + 1)として関数の外側にあるstate値を参照している。

setClickCount(prevCount => prevCount + 1) と書いた場合は実行時に常に最新の値を返してくれるから動く

clickCountはメモ化された瞬間の値になるので、初期値が0だったらどれだけボタンを押しても永遠に0のままである。

なぜこんな挙動なのか

そりゃメモされたものを返しているからね…

仮に上記の例をごく当たり前に解決するには

export function ToUseUseCallback({ ...rest }) {
  const [count, setCount] = useState(0)
  const [clickCount, setClickCount] = useState(0)

  const onIncrement = useCallback(() => {
    setCount(count => count + 1)
    setClickCount(clickCount + 1)
  }, [clickCount]) // ← depsに指定する
.
.
.

このようにdepsに依存している変数を書けば動く。

clickCountに依存しているのだから第二引数の配列(deps)にclickCountを指定してやれば当然clickCountが変わったら関数は新しくメモされるので期待した現在のclickCount値になる。

だがしかし、こんなカウンター程度ならまだいいものの、もっと頻繁に値が変わったりするものに依存している場合、もはやメモ化している意味がなくなってしまう…… 😇

例えばuseCallbackではなくuseEffectを用いてなにかしらsubscribeするときに、こういう風にタイトルの問題を回避するために外部変数を第二引数(deps)に指定すると、depsが変わるたびにsubscribeが発生してしまう……。

Issue & FAQ

言及はすでにいろいろある。

github.com

いろいろと議論されている

https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

公式のFAQに書かれている事柄でもある

それで我々はどうすればいいのか

FAQにかかれているようにuseEventCallbackというカスタムフックを作って、若干のハック技とともに回避できるよというはなし。

ただこれはエスケープハッチとして言及されているだけで推奨はされていない。

In either case, we don’t recommend this pattern and only show it here for completeness. Instead, it is preferable to avoid passing callbacks deep down.

と書かれていて、できるだけuseReducerを用いてdispatchして処理を外に出すほうが良いらしい。

上記で貼ったIssueでDan氏が言っているけれどreducerはrenderフェーズで実行されることが確約されているのでということらしい。

これは並行モード(concurrent mode)というのが次期Reactで実装される予定に絡むはなしだそうで、useEventCallbackカスタムフックでハックするのはイケてないとのこと。

とはいえuseReducer使うほどでもないときもありそうなので

勝手を知ったうえでuseEventCallbackを使うのは自己責任という免罪符付きでアリな場面もありそう(実際に使いたい場面があった)。

ただ上記の公式FAQのuseEVentCallbackのコードexampleはuseCallbackの代替なのにコールバックに引数を渡せないコードなので困ってしまった。

引数を渡せるuseEventCallbackを作った

引数渡せなくて困ったので渡せるタイプのヤツをTSで自分で書いた。

github.com

index.tsがソレ

npmにあげてない(エスケープハッチだから)ので、もし利用したい方は自己責任でコピペしてくださいという気持ちです。

ちなみに若干話がそれるけれどuseEventCallbackWithUseEffectというものも一応exportしています。

import {useEventCallback, useEventCallbackWithUseEffect} from './your-directories/useEventCallback'
  • useEventCallback
    • 内部ではuseLayoutEffectを使用
  • useEventCallbackWithUseEffect
    • 内部ではuseEffectを使用

という違い。

React公式FAQに書いてあるコード例ではuseLayoutEffectを使っていて、これはrenderフェーズを確約させるためのような気がしている。

ただuseLayoutEffectブロッキングが発生するので、どうなんだろうと思ってuseEffectを使うuseEventCallbackWithUseEffectも一応用意した感じ。

自分が実際に使いたかった場面はちょっと特殊で、renderフェーズ待たなくてもよさそうでuseEffectでも問題なさそうだったからこっちも用意したという背景がある。

このuseLayoutEffect使うかuseEffect使うかあたりの話、ハッキリと腹落ちしてないので知見あるかたは教えて欲しいです

話は戻って

そういうわけで、経緯がややこしく長くなりましたが再度例をみてみましょう。

codesandbox.io

『To use useEventCallback』のほうのカウンターアプリは"動く例"でした。

なるべく簡素なexampleにしたのでこの例では引数を渡していませんが…。

トータルクリック回数がちゃんとカウントされて動いています。エスケープハッチでも問題なさそうであればuseEventCallbackを使えば動いてよかったですね。

こんだけ長く書いてアレですが

useEventCallbackハックは公式に非推奨と書かれていることなのであまり積極的に使うべきではない…

そもそも例えばハンドラ内でprev値を元に新しくstateを更新したいとかなら

const App = ()=> {
  const [count, setCount] = useState(0)
  
  const onClick = useCallback(()=> {
    setCount(prev => prev + 1)
  },
  []
  )
.
.
.
}

という風にstateのアップデーターを関数式にすればいいので…。

とはいえstateの更新だけではなく外部変数を元になんかやるみたいなときには使いたくなる場面があるかもしれないので書いた。

ご利用は計画的にということで。