React18のTransitionの動作確認


React18のTransitionの動作確認をした。
useTransitionを使った例を用意し、React18のAPIを使わず同様の体験を提供すると、どのようになるかを確認する。また、startTransitionuseDeferredValueでも同じことができるか確認する。

先に結論

Reactの18の新機能を使わずとも似たような表現はできるが体験は提供できない。即応性に大きな差がでる。また、useTransitionではisPendingが用意されているががstartTransitionuseDeferredValueでもisPendingを実装できることも確認できた。

startTransitionが柔軟で表現力が高く、isPendingが不要ならstartTransitionで表記が単純にになるとも言えるし、isPendingを保持する必要があるためhookにする必要があるとも言えるだろう。また、DOMに状態を任せたフォームでもこれらが有効だろう。

useDeferredValuestartTransitionが使えない場合に便利で、コールバックでstartTransitionを差し込めない場合などに使えそうである。

Transitionの仕組みは、緊急で変更しなくてよい状態変更は一旦そのままの値でレンダリングを行い、再度レンダリングすることで実現されているようだ。また、Transitionが使われている場合は、不要になれば計算を途中で停止することも確認できた。
たいていの場合、導入できるのであればしたくなるだろう。

動作確認に利用したコードはCodeSandboxに実装し、この記事の最後に貼り付けています。

確認内容

useTransitionをつかってみる

checkboxを用意し、checkboxがチェック状態になると表示に時間かかるコンポーネントを描画するようなものを作成した。描画の重いコンポーネントとしてSlowComponentというのを用意しているが、slowModeをtrueにすると描画が重くなる。なお、10万要素ほど表示するが、ブロックしないようにちょっと工夫してある。

function Transition() {
  const name = "Transition"
  // checkboxの状態
  const [checked, setChecked] = useState(false);
  const [isPending, startTransition] = useTransition();
  // startTransition内で遅延してcheckboxの状態を反映する。これをつかって重いコンポーネントを制御する
  const [deferredChecked, setDeferredChecked] = useState(checked);

  const handleChange = () => {
      // すぐ反映できるものに使う状態はそのまま変える
      setChecked((n) => !n);
      startTransition(() => {
          // 変わると重いコンポーネントが表示される状態はstartTransition内で変更する
          setDeferredChecked((n) => !n);
      });
  };

  console.log("render App", {value: checked, deferredValue: deferredChecked, isPending});
  // memoしとかないとdeferredCheckedがtrueに変わるときにReactElementの再構築で重くなる
  const slow = useMemo(
      // slowModeがtrueのときとても重くなるコンポーネント
      () => <SlowComponent slowMode={deferredChecked}/>,
      [deferredChecked]
  );
  return (
      <Box>
          <div>{name}</div>
          <input type="checkbox" checked={checked} onChange={handleChange}/>
          {isPending && <div>loading...</div>}
          {slow}
      </Box>
  );
}

大きなポイントはcheckboxをめちゃくちゃ連打しても応答が良いことである。
状態は以下のように変化する。

状態 checked deferredChecked isPending
マウント直後 false false false
checkbox変更直後 true false true
SlowComponent描画開始 true true false

deferredCheckedを同時に変更しないことで、レンダリングが早く終わるのがポイントだろう。

ちなみに、SlowComponentの中身はこんな感じ

/**
 * SlowComponent用の補助コンポーネント
 */
const SlowDetail = ({index, slowMode, num}) => {
    console.log("render SlowDetail", {name, index, slowMode});
    if (slowMode) {
        return (
            <>
                {[...new Array(num)].map((n, i) => (
                    <div key={i}>{index * num + i}</div>
                ))}
            </>
        );
    }
    return null;
};

/**
 * slowMode trueで描画に時間かかるコンポーネント
 */
const SlowComponent = ({slowMode}) => {
    console.log("render SlowComponent", {slowMode});
    if (slowMode) {
        return (
            <div>
                {/* 一つのコンポーネントで大量に表示してしまうとブロックしてしまうので分割する */}
                {[...new Array(100)].map((n, i) => (
                    <SlowDetail key={i} index={i} num={1000} slowMode={slowMode}/>
                ))}
            </div>
        );
    } else {
        return <div>no slow</div>;
    }
};

React18の新機能を使わず似たような表現をしてみる

useTransitionの使い方がわかれば、React18のAPIを使わずとも同じようなことができそうな気がするだろう。やってみて比較してみよう。

function NoReact18_API() {
    const name = "NoReact18 API";
    const [checked, setChecked] = useState(false);
    const [deferredChecked, setDeferredChecked] = useState(false);

    const handleChange = () => {
        setChecked((n) => !n);
    };

    useEffect(() => {
        setTimeout(() => {
            setDeferredChecked(checked);
        }, 20); // 60FPSとすると16msとばせば描画されるだろう。ちょっと大きい20ms
    }, [checked])
    const isPending = checked !== deferredChecked;

    console.log("render App", {name, value: checked, deferredValue: deferredChecked, isPending});
    const slow = useMemo(
        () => <SlowComponent slowMode={deferredChecked}/>,
        [deferredChecked]
    );
    return (
        <Box>
            <div>{name}</div>
            <input type="checkbox" checked={checked} onChange={handleChange}/>
            {isPending && <div>loading...</div>}
            {slow}
        </Box>
    );
}

JSXの部分は全く同じである。一旦描画して、useEffect内であとからdeferredCheckedを更新することと描画されるようにちょっと間隔を空けてやると同様の表現ができた。ただし、連打したときの反応の良さがまるで違う。useTransitionをつかったほうがコードもすっきりすることが確認できる。

startTransitionを使ってみる

startTransitionをつかってみよう。isPendingは提供していないが、動作の仕方から推測し、同じように動くように実装してみている。

function StartTransition() {
  const name = "StartTransition";
  const [checked, setChecked] = useState(false);
  const [deferredChecked, setDeferredChecked] = useState(checked);

  const handleChange = () => {
    setChecked((n) => !n);
    startTransition(() => {
      setDeferredChecked((n) => !n);
    });
  };
  const isPending = checked !== deferredChecked;
  const slow = useMemo(() => <SlowComponent slowMode={deferredChecked} />, [
    deferredChecked
  ]);

  console.log("render App", { name, value: checked, deferredValue: deferredChecked, isPending });
  return (
    <Box>
      <div>{name}</div>
      <input type="checkbox" checked={checked} onChange={handleChange} />
      {isPending && <div>loading...</div>}
      {slow}
    </Box>
  );
}

checkedとdeferredCheckedを比較することでisPendingを用意した。isPendingを使う場合はuseTransitionを使うほうがひと手間少なく、確実であることが言えそうである。

useDeferredValueを使ってみる

useDeferredValueでも同様のことができる。内部実装を見るとわかるが、useEffectでtransitionを開始し、useDeferredValueで与えられた値を更新するような実装になっているようだ。((React18のAPIを使わない版はこれを参考にしたのは秘密である))

function DeferredValue() {
  const name = "DeferredValue";
  const [checked, setChecked] = useState(false);
  const deferredChecked = useDeferredValue(checked);

  const handleChange = () => {
    setChecked((n) => !n);
  };
  const isPending = checked !== deferredChecked;

  console.log("render App", { name, value: checked, deferredValue: deferredChecked, isPending });
  const slow = useMemo(() => <SlowComponent slowMode={deferredChecked} />, [
    deferredChecked
  ]);
  return (
    <Box>
      <div>{name}</div>
      <input type="checkbox" checked={checked} onChange={handleChange} />
      {isPending && <div>loading...</div>}
      {slow}
    </Box>
  );
}

CodeSandbox

ログをAppで絞ると状態の変化がわかりやすい。絞らない場合、途中でレンダリングをやめている様子がわかるようにしています。また、おまけでmemoしなかったバージョンを用意している。SlowComponentの表示がおわっているとを隠すときが重いことが確認できます。