Reactのお世話になって開発しているアプリで、慌てるあまりにお行儀の悪いコードを書いていたら、見事に刺されました。この記事は、その反省文です😇
3行まとめ
useState()
が返すsetXxxx()
関数にcallback関数を渡して使う時、このcallback関数は、即時実行の時と遅延実行の時とがある。- お行儀の良いコードを書いていれば、前述の差は特に意味は無いはずなので、良い子になりましょう。
- Reactのソースを読むのは練習が必要そう。
サンプルアプリケーション
npx create-react-app {appName}
でアプリを作った状況で、App.js
を以下の内容にしたものです。
import './App.css'; import { useState } from 'react'; function App() { const [money, setMoney] = useState(0); const onClick = () => { console.log('==========================='); console.log(performance.now().toFixed(10) + ' | 1 : クリックした'); setMoney((current) => { console.log(performance.now().toFixed(10) + ' | 2 : setMoneyのcallbackで副作用のある処理をする'); return current + 1; }); console.log(performance.now().toFixed(10) + ' | 3 : 後の処理をする'); } return ( <div className="App"> おかね : {money} <br/> <button onClick={onClick}>おかねもちになる</button> </div> ); } export default App;
起動してアクセスすると、以下のような画面が表示されます。「おかねもちになる」ボタンをクリックすると、数値が1ずつ増えていくという、よくあるサンプルです。
問題の挙動
「おかねもちになる」ボタンを2回クリックすると、ログにはこのように出力されました。
1回目のクリックでは、コードは書いてある順番通りに実行されたようですが、2回目のクリックでは順番が逆になり、callback関数の中身は「3 : 後の処理をする」の後に実行されたようです。
ここで私は、callback関数の中で副作用のあるコードを書き、setXxxxの後で「副作用が既に発動している前提」のコードというクソコード伸びしろだらけのコードを書いていたので、見事にやられました。
もう少し具体的に言うと、「後の処理」のところでは最新の money の値を使った処理をしたかったのですが、あいにくここはclosure*1の中ですので、古い値がcaptureされている危険性があります。そこで、setMoney関数には引数で必ず最新の値が渡されるという性質を利用して、ローカル変数をゴニョゴニョして……ということ*2をやっておりました😇
伸びしろだらけのコードを、どう改善する?
useStateフックで目的を達成するのはちょっと厳しそうだったので、 useRefフックを使った構成に置き換えることで対応しました。ちょっとコード修正量が多かったですけども、上手く修正できました。書かざるを得なかった「副作用」の内容によっては別の対策が必要になるでしょう。
なんで実行順序が変わるの?
裏付けは取れていないのですが、アプリの中でuseStateフックの setXxxx 関数を呼び出すと、どうやら dispatchSetState
という関数が呼ばれるよう*3です。
コードへのリンク : https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactFiberHooks.new.js#L2227-L2231
この関数のコメントには、以下のような記述があります。
// The queue is currently empty, which means we can eagerly compute the // next state before entering the render phase. If the new state is the // same as the current state, we may be able to bail out entirely.
「queueが空っぽだったら、つまり即時実行して大丈夫な状態だったら即時実行するよ」、的なことを言っていますね。
他にも諸条件はあるのかもしれませんが、前述のサンプルアプリにおいては、初回実行の時だけはqueueが空っぽのようです。
検証環境のバージョン情報
- React 18.1.0
- NodeJS v16.13.0
- macOS Monterey 12.3.1
- Google Chrome 102.0.5005.61