Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理

Concurrent Modeは、現在（2020年3月）実験的機能として公開されているReactの新しいバージョンです。Reactの次のメジャーバージョン（17.x）で正式リリースされるのではないかと思っていますが、確証はありません。なお、React公式からもすでに結構詳細なドキュメントが出ています。

- [並列モードの導入（実験的機能）](https://ja.reactjs.org/docs/concurrent-mode-intro.html)

Concurrent Modeに適応したアプリケーションを作るためには、従来とは異なる新しい設計が必要となります。筆者はConcurrent Modeを使ったアプリケーションをひとつ試作してみました。この記事から始まる「**Concurrent Mode時代のReact設計論**」シリーズでは、ここから得た知見を共有しつつ、Concurrent Mode時代に適応したReactアプリケーションの設計を提案します。

なお、Concurrent Modeはまだ正式リリース前の機能です。今後正式リリースまでの間にAPIの変更などが発生してこの記事の内容が当てはまらなくなる可能性は否定できませんが、その際はご容赦ください。

ちなみに、作ったアプリケーションはこれです。（宣伝）

- https://type-puzzles.uhyohyo.net/

プルリクエストも大募集しています。問題の追加はConcurrent Modeを理解していなくても大丈夫です。（宣伝）

- リポジトリ: https://github.com/uhyo/typescript-puzzle

## シリーズ一覧
- **Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理**
- [Concurrent Mode時代のReact設計論 (2) useTransitionを活用する](https://qiita.com/uhyo/items/ba49b25f0a206e933e4d)
- [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) まとめ（仮）

現在は(5)まで公開済です。

## イントロダクション

Concurrent ModeにおいてはReactの内部の実装が変更され、レンダリングの中断・再開をサポートするようになります。これにより、ユーザーの入力により素早く反応するなど、ReactアプリケーションのUX向上が期待できます。

Concurrent Modeは、useTransitionに代表される新しいAPIを搭載しており、Concurrent Modeを完全に活かすには新しいAPIを使いこなさなければいけません。useTransitionについては筆者の以前の記事が詳しいので、気になる方は合わせてお読みください。この記事の理解に必須ではありません。

- [🎊Reactの2種類の新フック「useTransition」と「useDeferredValue」を最速で理解する（プレビュー版）](https://qiita.com/uhyo/items/6be96c278c71b0ddb39b)

冒頭で述べた通り、このシリーズでは筆者がConcurrent Modeを試してみた経験を基にして、Concurrent Mode時代に適応したReactアプリケーションの設計を提案します。もちろんこれが唯一解であると主張したいわけではありませんが、最も基本的な考え方として通用するものだと考えています。

なお、このシリーズではステート管理やデータフェッチング用の外部ライブラリを使わない、最も基本的なConcurrent Mode向け設計を議論します。これから先Concurrent Modeに適応したライブラリが増えることと思いますが、そのライブラリを使う場合はまた異なる設計となるかもしれない点はご了承ください。まあライブラリを使うかどうかで設計が変わるのは当たり前の話ですが。

なお、実際に手を動かしながら読みたいという方向けに、TypeScript + React Concurrent Modeの設定がしてあるCodeSandboxを用意してあります。適当にいじって試してみましょう。

- https://codesandbox.io/s/react-concurrent-sandbox-17p7b

### 非同期処理の扱い方が変わる

React Concurrent Modeの最大の特徴として「Promiseを`throw`する」という衝撃的な仕様のみを知っていたという方も多いでしょう。Promiseというのは、非同期処理を表すのに非常に広く使われるオブジェクトです。

レンダリング時にPromiseを`throw`するには、コンポーネントがPromiseを持っている必要があります。コンポーネントがPromiseを持つ場合の選択肢は主にステートに持つ（`useState`とか）かrefで持つ（`useRef`）のどちらかです。もちろんpropsや`useContext`で受け取ることもできますが、それは親のコンポーネントが何らかの手段でPromiseを調達しているので本質的にはやはり前記のどちらかです。

一般に、レンダリング結果に関わるものを`useRef`で持つのは良くありません（後述しますが、Concurrent Modeではこれまで以上にこれを厳守する必要があります）。よって、**Promiseをステートに持つ**ことが必要になります。ただ、実際には生のPromiseでは機能不足なので、適当なラッパーを作ることになります（あとで具体例が出てきます）。

Promiseをステートに持つことで、コンポーネントは**「非同期処理の途中」というステートをもはや表現する必要がなくなります**。それは「レンダリングの中断（サスペンド）」で表せば良いのですから。つまり、例えば「データがあればロード済、データが無ければロード中」のようなロジックをコンポーネントが持つことは無くなります。

言い換えれば、コンポーネントはデータがロード中の場合の処理を気にする必要が無くなります。ただし、実際には「レンダリングの中断」の場合を別の場所（`Suspense`のフォールバック、あるいは`useTransition`のトランジション中状態）でハンドリングする必要がありますから、非同期処理について全く考えなくていいわけではありません。その意味では、より正確に言えばConcurrent Modeは**非同期処理の扱いをより疎結合に表現する手段を提供してくれる**というところでしょう。従来我々が手ずから扱っていた非同期処理対応の一部分を、Reactが組み込みの機能として受け持ってくれるという見方もできます。

## Concurrent Modeにおける非同期処理

では、改めてConcurrent Modeにおける非同期処理について説明します。

Concurrent Modeでは、コンポーネントがPromiseを投げることでサスペンド（レンダリングの中断）を表すことができます。その場合、当該のPromiseが解決されたら再度レンダリングが試みられます。まだ、サスペンドが発生したときに代替のビューを提供する機能が提供されます（`Suspense`や`useTransition`）。

これらの機能を使うことで、Concurrent Modeでは**より宣言的に非同期処理を扱えるようになった**と言えます。ただし、同時にこの機能は**Reactと非同期処理をより密結合なものにする**という側面を持ち合わせています。その意味で、ReactやConcurrent Modeでよりopinionatedなライブラリになったと言えます。

まずは、Concurrent Modeにおける基本的な非同期処理の例を示します。例を通してConcurrent Modeの感覚を掴みましょう。

まず、先ほど少し言及したPromiseのラッパーを定義します。

### Promiseをラップする`Fetcher<T>`

`Fetcher<T>`という名前は我ながら微妙な気がするのですが、いい命名が思いつかないので募集中です。`Fetcher<T>`は内部にPromiseを持っており、さらにPromiseが現在どういう状態なのか（`State<T>`）を知っています。これにより、「Promiseがまだ解決されていなかったらそのPromiseを投げる」という、Promiseの現在の状態に基づく分岐を実装しています。

```ts
type State<T> =
  | {
      state: "pending";
      promise: Promise<T>;
    }
  | {
      state: "fulfilled";
      value: T;
    }
  | {
      state: "rejected";
      error: unknown;
    };
```

この`State<T>`型はPromiseの3つの状態（解決前、成功、失敗）を表現する型です。解決前の場合はそのPromiseを、成功済みの場合は結果の値（`T`型）を、そして失敗の場合はエラーの値を保持します。この`State<T>`を用いて書かれた`Fetcher<T>`の実装は以下の通りです[^note_getOrUndefined]。

[^note_getOrUndefined]: 実際に上述のアプリで使われているバージョンではさらに`getOrUndefined`というメソッド（解決前だったら`throw`するのではなく`undefined`を返す）があるのですが、これが本質的に必要なのかは悩んでいます。設計力の不足により必要になってしまっただけかもしれません。

```ts
export class Fetcher<T> {
  private state: State<T>;
  constructor(fetch: () => Promise<T>) {
    const promise = fetch().then(
      value => {
        this.state = {
          state: "fulfilled",
          value,
        };
        return value;
      },
      error => {
        this.state = {
          state: "rejected",
          error,
        };
        throw error;
      },
    );
    this.state = {
      state: "pending",
      promise,
    };
  }

  public get(): T {
    if (this.state.state === "pending") {
      throw this.state.promise;
    } else if (this.state.state === "rejected") {
      throw this.state.error;
    } else {
      return this.state.value;
    }
  }
}
```

`Fetcher<T>`のコンストラクタはPromiseを返す関数を受け取ってすぐに呼び出します。ここで返されたPromiseの状態が監視され、`this.state`に反映されます。

`Fetcher<T>`が唯一もつメソッド`get()`は、Promiseが解決済だった場合はその値を返します。まだ解決されていない場合はPromiseを`throw`します。一応、Promiseが失敗していた場合はエラーを投げる処理も入れています。

ポイントは、`get`の返り値が`T`型になっている点です。Promiseを`throw`して大域脱出するという荒技によって、`get`を呼んだ側は非同期処理の途中かどうかを意識しなくても良くなります。何せ、`T`型の値が返ってきているということはもう`T`型の値がある、つまり非同期処理の結果があるということなのですから。つまり、`get()`を呼んで`T`型の値を得たコンポーネントは、あたかも非同期処理がすでに完了しているかのように処理を進めればよいのです。まだ完了していなかった場合はPromiseが投げられてしまいますが、その場合はReactが頑張って処理してくれます。

React Hooksが登場した時に「Algebraic Effectだ」なんて騒がれもしましたが、それと根本的な思想は同じです。すなわち、Reactが裏で頑張ることでシンプルなAPIを外向きに提供しているのです。

また、これだけ単純なラッパーでも、Promiseを投げるという点ですでにReactと癒着しています。しかし、前述の利点を得るためにはこれは欠かせません。これが、冒頭で触れた「Reactと非同期処理がより密結合になる」ということの意味です。

### `Fetcher`を使う例

`Fetcher`を使うコンポーネントは、例えばこんな見た目になります。

```tsx
type User = { id: string, name: string };

const UserList: FunctionComponent<{
  usersFetcher: Fetcher<User[]>,
}> = ({ usersFetcher }) => {
  const users: User[] = usersFetcher.get();
  return (
    <ul>
      {users.map(({ id, name }) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  );
};
```

`UserList`コンポーネントは受け取った`Fetcher<User[]>`の`get`メソッドをいきなり呼び出して`User[]`を取得します。あとはそれを適当に表示するだけです。ここで、`Fetcher<User[]>`は「`User[]`型の結果を取得する非同期処理」そのものを表しています。`get()`メソッドは、「その結果を取得する。まだ取得できない場合は取得できるまでサスペンドする」という意味になります。

この`UserList`コンポーネントは例えば次のように使用できます（`fetchUsers`が実際に`User[]`を取得する非同期処理を担当すると思ってください）。「Load Users」ボタンを押すと`usersFetcher`に`Fetcher<User[]>`のインスタンスが入って`UserList`がレンダリングされます。なお、`UserList`はサスペンドする可能性があるので、このように`Suspense`で囲んでフォールバックコンテンツ（中でサスペンドが発生したときに代わりにレンダリングされる内容）を指定しておく必要があります。

なお、`Suspense`の中身でサスペンドが発生した場合は`Suspense`の中身全体がフォールバックコンテンツに置きかわります。そのため、`Suspense`をどこに置くかは、レンダリングが中断した時にどこまでフォールバックコンテンツになってほしいかによって決めることになります。`Suspense`がネストしていた場合は一番内側の`Suspense`が反応します。

```tsx
const Container: FunctionComponent = () => {
  const [usersFetcher, setUsersFetcher] = useState<
    Fetcher<User[]> | undefined
  >();

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <p>
        <button
          onClick={() => {
            setUsersFetcher(new Fetcher(fetchUsers));
          }}
        >Load Users</button>
      </p>
      {usersFetcher ? <UserList usersFetcher={usersFetcher} /> : null}
     </Suspense>
  );
};
```

以上のようにして、実際に非同期処理を発生させて（`fetchUsers`を呼び出して）以降の流れが全部実装できました。これを実際に動作させると、非同期処理の途中は「Loading...」と表示されて読み込まれたら`UserList`の中身がレンダリングされます。

より具体的な流れとしては以下のことが発生しています。

1. `Container`内で`setUsersFetcher`が呼び出されることで`usersFetcher`ステートに`Fetcher`が入る。
2. `Container`が再レンダリングされて`UserList`がレンダリングされる。
3. `UserList`がレンダリングされる（関数`UserList`が呼び出される）最中に、`get()`でPromiseが`throw`される（`UserList`がサスペンドする）。
4. サスペンドが発生したので、`Suspense`の中身として`<p>Loading...</p>`がレンダリングされる。
5. しばらくして`usersFetcher`が返したPromiseが解決される。
6. ReactがPromiseの解決を検知し、以前サスペンドした`UserList`が再レンダリングされる。
7. 今回は`get()`がPromiseを投げない（解決済のため）ので`UserList`はサスペンドされずに描画される。

一応画面の動きを示しておくと、このようになります。

![screenshots-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233667/56b7896d-5155-8da4-61c6-1764ffe290f1.png)

### 従来の方式との比較

一応、従来の方式（Concurrent Modeより前の書き方）との比較を行なっておきます。一例ですが、素朴に書くならこんな感じでしょう。

```tsx
const Container: FunctionComponent = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [users, setUsers] = useState<User[] | undefined>();

  return (
    <>
      <p>
        <button
          onClick={() => {
            setIsLoading(true);
            fetchUsers().then(users => {
              setIsLoading(false);
              setUsers(users);
            });
          }}
        >
          Load Users
        </button>
      </p>
      {isLoading ? (
        <p>Loading...</p>
      ) : users ? (
        <UserList users={users} />
      ) : null}
    </>
  );
};
```

ロード中・ロード完了という状態を表すために`isLoading`というステートが新設されました（[TypeScript wayでReactを書く](https://qiita.com/uhyo/items/d74af1d8c109af43849e)で説明したようにこれはベストなステートの表現ではありませんが、今回の本質にはあまり関わりません）。ボタンがクリックされたときは、「ローディング状態をにする→非同期処理を発火→終わったら結果をステートに反映」というステップを踏みます。

Concurrent Modeに比べるとやはり複雑化しており、とくに`Container`コンポーネントが非同期処理をハンドリングするためのロジックを内包するようになったのが気になります。これが非同期処理の辛い点であり、各種のライブラリが頑張って解決しようとしている点でもあります。

Concurrent Modeは、これに対して「非同期処理を表すオブジェクトそのものをステートに突っ込む」という斬新な解決策を提示しました。これは、非同期処理の扱いのつらい部分をサスペンドという機構に押し込むことで達成されています。

### Concurrent Modeにおけるエラー処理

ここまでの例ではエラー処理を全く扱ってきませんでしたが、Concurrent Modeでは非同期処理に係るエラー処理も様変わりします。

というのも、非同期処理はPromiseで表されますが、Promiseというのは失敗（reject）する可能性があります。非同期処理におけるエラーはPromiseの失敗で表されます。では、`throw`したPromiseが失敗したらどうなるのでしょうか。

答えは、**Error Boundaryでキャッチされます**。Error BoundaryはReact 16で導入された機能で、コンポーネントのレンダリング中にエラーが発生した場合にそれをキャッチしてエラー時のコンテンツをレンダリングできるものです。

従来は、非同期処理によるエラーはError Boundaryではキャッチされず、自前でハンドリングして必要なら自前でいい感じにUIに反映させるロジックを書く必要がありました。それは、非同期処理によって発生したエラーはレンダリング中に発生したエラーではないからです。

Concurrent ModeではPromiseを`throw`するという機構によって非同期処理がレンダリングによって組み込まれますから、非同期処理によって発生したエラーもレンダリング中に発生したエラーとして扱われるのは自然なことです。

Error Boundaryは宣言的なエラー処理機構なので、Concurrent Modeでは非同期処理に対しても宣言的なエラー処理が可能になったということです。たいへん嬉しいですね。

## まとめ

この記事では、Concurrent Modeの基礎である「Promiseを`throw`する」という方針を実現するためにPromiseをステートに持って扱う方法について説明しました。これにより、より宣言的に非同期処理を扱えるようになると共に、エラー処理をError Boundaryの機構で統一的に扱えるようになりました。

次の記事: [Concurrent Mode時代のReact設計論 (2) useTransitionを活用する](https://qiita.com/uhyo/items/ba49b25f0a206e933e4d)