備忘録: React useStateのバッチ処理と落とし穴

はじめに

Reactのフックの中で最も多用されるであろうuseState。複数ステートを同時に更新しても、Reactがバッチにまとめてくれるので、通常はその更新頻度やタイミングを気にすることは無い。しかし条件によってはバッチ処理が行われず、余分なレンダリングや不整合が発生することがある。この問題の発生するケースとその回避策についてまとめて見た。

複数ステートの更新とバッチ処理

コンポーネントの中に複数のステートを持つことは良く見られるパターンである。複数のステートを同時に更新した場合、それらの更新はReactによりバッチとしてまとめられる。このため複数のステートが更新されても、レンダリングは一度だけトリガーされる。これには次のようなメリットがある。

  • 不要なレンダリングを抑制することでパフォーマンスを向上する
  • 一部のステートのみが更新された状態でレンダリングされることによる不整合を防ぐ

実はこのバッチ処理は、React 18 (執筆時点での最新版) とReact 17以前とでは異なっている。

React 17以前では、バッチ処理はReactの管理するイベントに対してのみ行われていた。これはキーボードやマウスからのイベントが該当する。Reactのコンポーネントで、onClickなどのイベントハンドラーを登録した場合は、そのイベントハンドラーで複数のステートを更新しても、レンダリングは一度だけトリガーされる。

これに対してReactの管理しないイベントではバッチ処理が行われない。このようなイベントは、fetchやsetTimeoutから発生するイベントが該当する。

React 18では、このReactの管理しないイベントでもバッチ処理が行われるようになった。

Automatic batching for fewer renders in React 18 · reactwg/react-18 · Discussion #21
Overview React 18 adds out-of-the-box performance improvements by doing more batching by default, removing the need to m...

ただしReact 18でもこのバッチ処理が適用されないケースがある。この場合は複数回のレンダリングが発生する他、ステートの更新状況も中間的な状況 (更新済みのステートと未更新のステートが混在) となる。

バッチ処理が行われるケース

まず最初に期待通りバッチ処理が行われるケースを見てみる。次のコードでは、useEffectの中で外部のリソースにアクセスするユースケースを想定している。ただしリソースへのアクセス部分は、setTimeoutを使って代用している。(テスト環境としてcodepenを使っている。Reactのバージョンは18だが、コードの中でReactVersionを指定することでReact 17相当の動作もできる)

const useState = React.useState;
const useEffect = React.useEffect;
const createRoot = ReactDOM.createRoot;

const ReactVersion = 18;

function App() {
  const [x, setX] = useState<number>(0);
  const [y, setY] = useState<number>(0);

  useEffect(() => {
    doSomething();
  },[]);
  
  async function doSomething() {
    await fetchVoid();
    setX(1);
    setY(1);
  }
  
 function fetchVoid() {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log('Resolved');
        resolve();
      }, 1000);
    });
  }

  console.log(`Rendering ... x=${x}, y=${y}`);

  return (
    <p>
      x: {x}, y: {y}
    </p>
  );
}

if (ReactVersion === 18) {
  const container = document.getElementById("root");
  const root = createRoot(container);
  root.render(<App />);
}
else {
  ReactDOM.render(<App />, document.getElementById("root"));
}

この実行結果は次のようになる。レンダリング時には、x, y共に最新のステートが得られている。

"Rendering ... x=0, y=0"
"Resolved"
"Rendering ... x=1, y=1"

ちなみに次のようにバージョンを設定することで、React 17での動作を確認できる。

const ReactVersion = 17;

この実行結果は次のようになる。この場合はバッチ処理が行われず、xのみが更新された中間状態のレンダリングが発生している。

"Rendering ... x=0, y=0"
"Resolved"
"Rendering ... x=1, y=0"
"Rendering ... x=1, y=1"

バッチ処理が行われないケース

次にバッチ処理が行われないケースを見てみる。

先ほどのコードで、doSomethingの中を次のように書き換えて、リソースへのアクセスを複数回行うようにする。

 async function doSomething() {
    await fetchVoid();
    setX(1);
    setY(1);
    await fetchVoid();
    setX(2);
    setY(2);
  }

この実行結果は次のようになる。

"Rendering ... x=0, y=0"
"Resolved"
"Rendering ... x=1, y=1"
"Resolved"
"Rendering ... x=2, y=2"

レンダリングサイクルが1回増えているのが分かる。それでもx, yの値は常に同時に更新されているので、中途半端な更新による不整合は発生しない。その点はReact 18の恩恵ではある。

この実験から分かるのは、バッチ処理が行われるのは、一つのイベント処理の中に限定されるということである。上記の例ではfetchVoidが2回呼ばれることで、実際には2回のイベントが発生している。複数のイベントにまたがるステートの更新はバッチされない。

不整合が発生するケース

より現実的なユースケースとして、x, yをそれぞれ異なるリソースから読み込むようにしてみた。

const useState = React.useState;
const useEffect = React.useEffect;
const createRoot = ReactDOM.createRoot;

const ReactVersion = 18;

function App() {
  const [x, setX] = useState<number>(0);
  const [y, setY] = useState<number>(0);

  useEffect(() => {
    doSomething();
  },[]);
  
  async function doSomething() {
    const newX =  await fetchX();
    setX(newX);
    
    const newY = await fetchY();
    setY(newY);
  }
  
 function fetchX() {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log('Fetch X Completed ...');
        resolve(x + 1);
      }, 1000);
    });
  }
  
 function fetchY() {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log('Fetch Y Completed ...');
        resolve(y + 1);
      }, 1000);
    });
  }

  console.log(`Rendering ... x=${x}, y=${y}`);

  return (
    <p>
      x: {x}, y: {y}
    </p>
  );
}

if (ReactVersion === 18) {
  const container = document.getElementById("root");
  const root = createRoot(container);
  root.render(<App />);
}
else {
  ReactDOM.render(<App />, document.getElementById("root"));
}

実行結果を次に示す。

"Rendering ... x=0, y=0"
"Fetch X Completed ..."
"Rendering ... x=1, y=0"
"Fetch Y Completed ..."
"Rendering ... x=1, y=1"

先の実験では、レンダリング時点でのx, yの値は、見かけ上は同時に更新されていたが、今回は途中でxのみが更新された中間状態が現れている。本来、x, yの更新が終わった状態が見せたい状態だとすると、中間状態は不整合な状態となる。(開発者の意図にもよるが …)

問題の回避方法

問題の回避方法は至って簡単。次のようにステートの更新を最後にまとめることで、ステートの更新をバッチにまとめることができる。もし2つのawaitをPromise.allを使って一つにまとめられるならば、その後にステートを更新しても良い。いずれも最後のイベントの処理中に、全てのステートを更新していることになる。

const useState = React.useState;
const useEffect = React.useEffect;
const createRoot = ReactDOM.createRoot;

const ReactVersion = 18;

function App() {
  const [x, setX] = useState<number>(0);
  const [y, setY] = useState<number>(0);

  useEffect(() => {
    doSomething();
  },[]);
  
  async function doSomething() {
    const newX =  await fetchX();
    const newY = await fetchY();
    
    setX(newX);
    setY(newY);
  }
  
 function fetchX() {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log('Fetch X Completed ...');
        resolve(x + 1);
      }, 1000);
    });
  }
  
 function fetchY() {
    return new Promise(resolve => {
      setTimeout(() => {
        console.log('Fetch Y Completed ...');
        resolve(y + 1);
      }, 1000);
    });
  }

  console.log(`Rendering ... x=${x}, y=${y}`);

  return (
    <p>
      x: {x}, y: {y}
    </p>
  );
}

if (ReactVersion === 18) {
  const container = document.getElementById("root");
  const root = createRoot(container);
  root.render(<App />);
}
else {
  ReactDOM.render(<App />, document.getElementById("root"));
}

実行結果を次に示す。x, yの更新後にレンダリングされている。

"Rendering ... x=0, y=0"
"Fetch X Completed ..."
"Fetch Y Completed ..."
"Rendering ... x=1, y=1"

終わりに

分かってみれば回避策は単純である。ただ「関連する処理はできるだけまとめる」というプログラミングの原則に立てば、「不整合が発生するケース」のコードは、普通に書いてしまうだろう。また筆者の場合は、React 17でこの件 (不整合の発生) で苦労したこともあり、今後のために備忘録として残すことにした。

更新履歴

日付内容
2023/09/13初版リリース