Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか

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

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

## `Suspense`や`useTransition`が何を解決するか

前回までは、Promiseを`throw`して`Suspense`がキャッチするというConcurrent Modeの特徴、そして「非同期処理そのもの（Promise）をステートで管理する」という設計指針において欠かせない部品である`useTransition`について見てきました。

`useTransition`は「2つのステートを同時に扱う」という斬新な概念を導入しました。そうまでしてConcurrent Modeが「Promiseをステートで管理する」という設計を貫く理由はおもに3つあると考えられます。まず**非同期処理にまつわるロジックを分割する**ため、そして**非同期処理をより宣言的に扱う**ためです。最後に、これは公式ドキュメントでも強調されていることですが、**render-as-you-fetchパターンの実現**です。ここからは、この3つを達成するためにどのような設計が必要かについて議論します。

前回出てきた「画面Aから画面Bに遷移するためにデータを読み込んでいる間は、画面Aに留まって読み込み中の表示にしたい」というシチュエーションについて再考してみます。従来（Concurrent Modeより前）の考え方では、画面Bへの遷移は2つの段階に分割できます。すなわち、「画面B用のデータをロード中の段階」と「ロードが終わって画面Bをレンダリングする段階」です。

この指針に基づいて作った従来型の実装をまず考えてみます。

### 非同期処理を含む画面遷移の従来型実装

画面Aと画面Bという2つの画面が存在しますから、今どちらの画面かといったステートを司る存在が必須です。とりあえずこれを`Root`と呼びましょう。画面Bは前回から例に出てきている`User[]`型のデータを表示するとすると、`Root`はこんな感じで定義できます。

```tsx
type AppState =
  | {
      page: "A";
    }
  | {
      page: "B";
      users: User[];
    };

export const Root: FunctionComponent = () => {
  const [state, setState] = useState<AppState>({
    page: "A"
  });
  const goToPageB = () => {
    fetchUsers().then(users => {
      setState({
        page: "B",
        users
      });
    });
  };

  if (state.page === "A") {
    return <PageA goToPageB={goToPageB} />;
  } else {
    return <PageB users={state.users} />;
  }
};
```

`Root`コンポーネントの最後に注目すると、今画面Aにいるときは`PageA`をレンダリングし、画面Bにいるときは`PageB`をレンダリングするようになっています。画面Aは画面Bに行くボタンを持っている想定なので`goToPageB`という関数をpropsで受け取ります。一方の画面Bは`User[]`を表示するので`User[]`をpropsで受け取ります。`goToPageB`が呼ばれた場合、`fetchUsers()`が完了するまでは現在の画面にとどまり、完了し次第`setState`により画面Bを表示という実装です。

`PageA`の実装はこんな感じになりますね。

```tsx
const PageA: FunctionComponent<{
  goToPageB: () => void;
}> = ({ goToPageB }) => {
  const [isLoading, setIsLoading] = useState(false);
  return (
    <p>
      <button
        disabled={isLoading}
        onClick={() => {
          setIsLoading(true);
          goToPageB();
        }}
      >
        {isLoading ? "Loading..." : "Go to PageB"}
      </button>
    </p>
  );
};
```

画面Aは「画面B用のデータを読み込み中はローディング中の表示にする」というロジックのために`isLoading`ステートを持っています。それ以外は特筆すべき点はありませんね。このステートを`PageA`の内部に持つか、それとも前述の`AppState`の一部にするかは一考の余地がありますが、どちらも一長一短です。

この設計では、「画面Bのデータをロード中の段階」は、`PageA`の`isLoading`ステートが`true`になり、`Root`が`fetchUsers()`の結果を待っている段階として現れます。そして、「ロードが終わって画面Bをレンダリングする段階」は`Root`の`setState`でステートを変更して画面Bをレンダリングする部分に対応しています。

### 従来型設計の欠点と限界

この設計（従来型設計）で注目すべきは、ページ遷移に係るロジックが`Root`に集約されているという点です。ページ遷移というのはそもそもページ横断的なロジックなので、`Root`が一枚噛んでいることは不自然ではありません。

しかし、「画面B用のデータを待つ」という機能を`PageB`ではなく`Root`が担っている点が残念です。今回のように単純なパターンならば大きな問題にはなりませんが、Reactが提唱する「render-as-you-fetch」パターンを実装したいときに問題となります。また、細かいことをいえば、「`fetchUsers()`の結果が帰ってきたら`setState`する」という処理は命令的な書き方であり、宣言的にUIを記述する流れに逆行しています。

ここで登場したrender-as-you-fetchパターンとは何かというと、複数のデータを表示してロードする際に、ロードできた部分から順次表示していくというパターンです。なるべく早く情報を表示するという目的のためにこの戦略が取られることもあるでしょう。そして明らかに、これを実現するには「データを待つ」という部分が画面Bの中で制御される必要があります。上述の「データがロードされるまで画面Bに制御を渡さない」という設計はこれと明らかに逆行しています。

さらに、これと上記の要件を組み合わせると、「画面Bのメインのデータがロードできるまでは画面Aに留まるが、それ以外のデータがまだでも画面Bに遷移して良い」みたいな仕様が誕生するかもしれません。これをそのまま実現しようとすると、データローディングのロジックが`Root`内と画面B内に分割され、設計が壊滅的状況に陥ります。

すぐに思い当たる解決策は「メインのデータのみ`Root`で読み込んで、それ以外のデータは画面Bがレンダリングされた後に`useEffect`なり何なりから別途非同期処理を発火して読み込む」というものです。しかし、これには「メイン以外のデータの読み込みが画面Bがレンダリングされるまで始まらない」という致命的な問題があります。最近のWebアプリケーションにとってパフォーマンスは命なので、たかだか設計の都合程度の理由でデータ読み込み開始を送らせていいわけがありません。

ということで、ベストなUXを追求しようとすれば、手続き的なロジックにまみれた壊滅的な設計ができあがります。Concurrent Modeはこの状況に一石を投じました。

### Concurrent Mode時代のデータローディング設計

前項で挙がった問題を纏めると、**データを待つというロジックを`Root`が握っていること**、**ロジックが手続き的であること**、そして**render-as-you-fetchパターンが困難であること**でした。

次は、これらの問題を解決するためのConcurrent Mode的設計パターンを見ていきます。まず`Root`はこのように書き換えられるでしょう。

```tsx
type AppState =
  | {
      page: "A";
    }
  | {
      page: "B";
      usersFetcher: Fetcher<User[]>;
    };

export const Root: FunctionComponent = () => {
  const [state, setState] = useState<AppState>({
    page: "A"
  });
  const goToPageB = () => {
    setState({
      page: "B",
      usersFetcher: new Fetcher(() => fetchUsers())
    });
  };
  return (
    <Suspense fallback={null}>
      <Page state={state} goToPageB={goToPageB} />
    </Suspense>
  );
};

const Page: FunctionComponent<{
  state: AppState;
  goToPageB: () => void;
}> = ({ state, goToPageB }) => {
  if (state.page === "A") {
    return <PageA goToPageB={goToPageB} />;
  } else {
    return <PageB usersFetcher={state.usersFetcher} />;
  }
};
```

まず`Root`内に目を向けると、`fetchUsers()`は`new Fetcher()`の中に押し込まれました。これにより、`goToPageB`が持つロジックはステートを画面Bのものに更新するだけになりました。

新しく`Page`というコンポーネントができて`state.page`による分岐が`Page`の中に入りましたが、これはページの外側に`Suspense`を配置することが目的です。`Suspense`コンポーネントをどこに配置すべきかは別途解説しますが、今回のようにページ遷移でサスペンドが発生するかもしれないときはページより外側に配置するのが適しています。いちいち`goToPageB`を受け渡す必要があるのがダサいと思われるかもしれませんが、それはコンテキストなり何なりを使って解消できるのであまり本質的な問題ではありません。

続いて、`PageA`コンポーネントはこのようになります。

```tsx
const PageA: FunctionComponent<{
  goToPageB: () => void;
}> = ({ goToPageB }) => {
  const [startTransition, isPending] = useTransition({
    timeoutMs: 10000
  });
  return (
    <p>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(() => {
            goToPageB();
          });
        }}
      >
        {isPending ? "Loading..." : "Go to PageB"}
      </button>
    </p>
  );
};
```

`isLoading`を`useState`で宣言するのをやめて`useTransition`を使うようになりました。画面Bへの遷移（`goToPageB()`）を`startTransition`で囲むことで、遷移時にサスペンドが発生したらボタンに`Loadinng...`が表示されるという制御がされています。

目ざとい方は、この設計は微妙だと思ったかもしれません。というのも、`startTransition`は中でステートを更新することで意味を発揮する関数なのに、`goToPageB`という関数は「画面Bに遷移する」という抽象化された意味を持たされており、中でステートの更新が行われることが明らかではありません。今回は`goToPageB`の実態が`setState({ ... })`なので偶々うまくいっていますが、`startTransition`と`setState`という2つがセットで扱われないといけないことが設計に現れていないのがどうにも微妙です。

Reactの公式ドキュメントを読む限りはこれが大きな問題であるとは考えられていないようですが、個人的には改善の余地ありと感じるところです。

最後の`PageB`は特筆すべきところがありませんが、一応出しておきます。

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

以上のコードでは、最初に述べた従来の設計の3つの問題が解消されています。まず、「データを待つというロジックを`Root`が握っていること」及び「ロジックが手続き的であること」については、`Root`が持つロジックが`setState`だけになったことによって解消されました。画面Bがデータを待つという部分も、Suspenseの機能および`Fetcher`によって、手続き的な部分がReactの内部に隠蔽され、宣言的な書き方ができています。

最後の「render-as-you-fetchパターンが困難であること」については、この例が簡単なので現れていません。これについては次の記事で詳しく扱います。

## まとめ

この記事では、ページ遷移という課題を例にとり、従来型の設計とConcurrent Mode時代の設計を比較し、Concurrent Modeによって従来存在した問題が解決できることを示しました。

尤も、何が問題で何か問題でないかということについて唯一解は存在しませんから、Concurrent Modeの視点からということにはなります。Reactはだんだんとopinionatedなライブラリの色を強くしてきていますから、この記事の内容に同意できなくてもそれは悪いことではありません。

この記事までが「Concurrent Mode時代のReact設計論」シリーズの前半です。前半ではConcurrent Modeの基礎を解説し、Concurrent Modeがどのような問題を解決したいのかについて示しました。

シリーズ後半では、Concurrent Modeを前提とした設計について議論します。先ほど少しだけ触れたように、この記事で出てきたConcurrent Modeのコードは従来の問題を解決しますが、これがベストな設計かどうかは疑う余地があります。次回以降の記事では、Concurrent Modeの恩恵をより受けるためにどのような設計がベストかについて考えていきます。

次の記事: [Concurrent Mode時代のReact設計論 (4) コンポーネント設計にサスペンドを組み込む](https://qiita.com/uhyo/items/4c45672874874a2aad11)