職業プログラマの休日出勤

職業プログラマによる日曜自宅プログラミングや思考実験の成果たち。リアル休日出勤が発生すると更新が滞りがちになる。記事の内容は個人の意見であり、所属している(いた)組織の意見ではない。

ReactのuseStateのcallback実行タイミング

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回クリックすると、ログにはこのように出力されました。

consoleログの出力内容

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.

出典 : https://github.com/facebook/react/blob/v18.1.0/packages/react-reconciler/src/ReactFiberHooks.new.js#L2262-L2264

「queueが空っぽだったら、つまり即時実行して大丈夫な状態だったら即時実行するよ」、的なことを言っていますね。
他にも諸条件はあるのかもしれませんが、前述のサンプルアプリにおいては、初回実行の時だけはqueueが空っぽのようです。

検証環境のバージョン情報

*1:実際に問題を踏んだアプリケーションのコードは、もう少し複雑です。

*2:良い子の皆さんは絶対にマネしてはいけません!!!

*3:デバッガのステップ実行では、こんな名前の関数が呼ばれていることを確認済みなのですが、デバッガで見えているコードが、後述のリンクしているコードそのものであることの裏付けが取れていません。