Concurrent Mode時代のReact設計論 (2) useTransitionを活用する

この記事は「Concurrent Mode時代のReact設計論」シリーズの2番目の記事です。

## シリーズ一覧
- [Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理](https://qiita.com/uhyo/items/4a6315bfccf387407631)
- **Concurrent Mode時代のReact設計論 (2) useTransitionを活用する**
- [Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか](https://qiita.com/uhyo/items/27c1282addc06b17a980)
- [Concurrent Mode時代のReact設計論 (4) コンポーネント設計にサスペンドを組み込む](https://qiita.com/uhyo/items/4c45672874874a2aad11)
- [Concurrent Mode時代のReact設計論 (5) トランジションを軸に設計する](https://qiita.com/uhyo/items/52ab8e2cab30b0811294)
- [Concurrent Mode時代のReact設計論 (6) Concurrent Modeと副作用](https://qiita.com/uhyo/items/fa443351fd4444dacaf7)
- Concurrent Mode時代のReact設計論 (7) ステート管理ライブラリの展望（仮）
- Concurrent Mode時代のReact設計論 (8) まとめ（仮）

## `useTransition`を活用する

前回の記事ではConcurrent Modeの基礎的な機能と、それを扱うための考え方を説明しました。ボタンを押すとステートに`Fetcher`が突っ込まれて、それにより再レンダリング・サスペンドが発生するという流れでした。

実は、その例ではサスペンドが発生した際に次のようなワーニングが発生します。

```
Warning: Container triggered a user-blocking update that suspended.

The fix is to split the update into multiple parts: a user-blocking update to provide immediate feedback, and another update that triggers the bulk of the changes.

Refer to the documentation for useTransition to learn how to implement this pattern.
```

これは、ボタンの`onClick`のようにユーザーの操作をきっかけとして、再レンダリング→サスペンドが発生したときに表示されるワーニングです。これが意味するところを噛み砕いて説明すると、「**ユーザーの入力に対してはすぐにフィードバックを返すべきだから、サスペンドする（＝新しいステートが表示されるまでに時間がかかる）のは良くない**」ということです。

そして、このワーニングに対する対処法はずばり**`useTransition`を使うこと**です。`useTransition`を使うことで、**ステートの更新でサスペンドが発生した場合に元々のステートを基にフィードバックを描画できる**のです。

### `useTransition`の使用例

さっそく、先ほどの例に`useTransition`を追加してみましょう。`useTransition`はユーザーへのフィードバックを念頭に置いた機能なので、ユーザーへのフィードバックとしてボタンを押したらローディング中はボタンがdisabledになるという実装を入れてみましょう。`Container`をこのように変更します。

```tsx
const Container: FunctionComponent = () => {
  // useTransitionの呼び出しを追加
  const [startTransition, isPending] = useTransition({
    timeoutMs: 10000
  });
  const [usersFetcher, setUsersFetcher] = useState<
    Fetcher<User[]> | undefined
  >();

  return (
    <>
      <p>
        <button
          onClick={() => {
            // ステート更新をstartTransitionで囲む
            startTransition(() => {
              setUsersFetcher(new Fetcher(fetchUsers));
            });
          }}
          // isPendingがtrueのときはdisabledに
          disabled={isPending}
        >
          {isPending ? "Loading..." : "Load Users"}
        </button>
      </p>
      <Suspense fallback={<p>Loading...</p>}>
        {usersFetcher ? <UserList usersFetcher={usersFetcher} /> : null}
      </Suspense>
    </>
  );
};
```

`useTransition`はフックの一種なので、このように関数コンポーネントから呼び出します。結果は`startTransition`関数と`isPending`（真偽値）の組です。この`startTransition`はボタンの`onClick`ハンドラの中で使われており、ステートの更新が`startTransition`で囲われています。`startTransition`に渡されたコールバック関数は即座に呼び出されます。

この実装では、ボタンを押すと以下のスクリーンショットのような挙動となります。

![screenshots-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233667/8db7c42c-be17-14b8-1732-f9327dfef135.png)

これを理解するために。`useTransition`の挙動を簡単に説明します。`startTransition`の内部で行われたステートの更新がサスペンドを発生させた場合、変更後ではなく変更前のステートがレンダリングされます。ただし、このとき変更前のステートでは`useTransition`が返す`isPending`が`true`になっています。投げられたPromiseが解決された場合は変更後のステートで再レンダリングされます。

`useTransition`を使わない場合との違いはサスペンド中に現れます。`useTransition`を使わない場合は`Suspense`によるフォールバックが表示されますが、`useTransition`を使う場合はフォールバックは表示されず、代わりにステート更新前の状態が（`isPending`が`true`で）レンダリングされるのです。

`useTransition`にオプションとして渡した`timeoutMs`は、この「`isPending`が`true`の状態」の最大持続時間を表します。この時間が過ぎてもPromiseが解決されなかった場合、諦めて変更後のステートがレンダリングされます。ただし、まだPromiseが解決されていないので`Suspense`によりフォールバックが表示されます。

ボタンがクリックされてからの流れは次のようになります。

1. 初期状態では、`usersFetcher=undefined, isPending=false`である。（上のスクリーンショットの左の状態）
2. `startTransition`内で`setUsersFetcher`が呼ばれ、`usersFetcher`ステートが更新される。（このとき`new Fetcher`で作られたオブジェクトを`F`とする）
3. `useTransition`の効果ににより、まず`usersFetcher=undefined, isPending=true`の状態で`Container`がレンダリングされ、DOMに反映される。（上のスクリーンショットの真ん中の状態）
4. 次に、新しいステート（`usersFetcher=F, isPending=false`）で`Container`がレンダリングされる。これは`UserList`のレンダリングに繋がり、`UserList`のレンダリングはサスペンドする。`useTransition`の効果により、この状態はDOMに反映されない。
5. `F`が持つPromiseが解決されると、新しいステート（`usersFetcher=F, isPending=false`）で`Container`が再レンダリングされる。今回はサスペンドが発生せずにレンダリングが完了し、この状態がDOMに反映される。（スクリーンショットの右の状態）

ポイントは、`useTransition`内でステートの更新を行なった場合、新しいステートよりも「元のステート+`isPending=true`」のレンダリングが優先されるということです。これは、`isPending=true`の状態でユーザーへのフィードバックを表すことを意図しているためです。ユーザーへのフィードバックは最優先で画面に反映されるべきであるため、これが最初に処理されます。

ちなみに、`startTransition`の中と外の両方でステートの更新を行うことができます。この場合、`startTransition`の外で行なった更新は3の段階で反映されています（もちろん5の段階にも反映されます）。

また、`timeoutMs`で設定した時間を超えない限り、`Suspense`の`fallback`で指定した内容は表示されなくなります。`useTransition`をきちんと使っている限りは、`Suspense`の`fallback`はいわば最終防衛ラインのような扱いになり、高頻度でユーザーが目にするものではなくなります。

### `useTransition`の必要性

Concurrent Modeにおける設計ではPromiseをステートに持つことになると前回述べましたが、この立場では`useTransition`の存在は必然的なものとなります。

そもそも、アプリの状態・画面表示といったものの変化は、Reactにおいてはステートの変化として表されます。ステートの変化によって起こることは再レンダリングです。そして、非同期処理によって発生するサスペンドは、再レンダリングの結果として起こります。

ということは、当然ながら、ステートを更新しないとサスペンドが発生しないということです。ステートを更新するということは、（`Suspense`によるフォールバックになるかもしれませんが）新しい画面がレンダリングされるということであり、そうなると普通は古いステートは捨てられます。

しかし、これは時に問題となります。例えば、「画面Aから別の画面Bに遷移したい。ただし、画面Bを表示するには非同期処理によるデータの読み込みが必要」という場合を考えてみましょう。しかも、データの読み込み中は画面Aに留まって読み込み中の表示にしたいとします。このとき、非同期処理が完了し次第画面Bに遷移するようにするには、とにかく画面Bをレンダリングしてサスペンドさせる必要があります。しかし画面Bをレンダリングしてしまうと画面Aは消えてしまいます。

この問題に対して、`useTransition`は「古い状態（画面A）と新しい状態（画面B）を同時に扱う」という方法で対処します。これはちょうど、gitでブランチを切って2つのバージョンのステートをメンテナンスするようなものです（Reactの公式ドキュメントでもこの例えが用いられています）。これによって、「まだ画面には反映されないけど新しいステートをレンダリングする」ということが可能になりました。

## まとめ

この記事ではReactが発するワーニングをきっかけとして`useTransition`を導入しました。Promiseをステートに入れるという設計方針をとったとき、`useTransition`は欠かせない部品となります。

次回は、なぜそこまでしてPromiseをステートに入れたいのかについて議論します。

次の記事: [Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか](https://qiita.com/uhyo/items/27c1282addc06b17a980)