Help us understand the problem. What is going on with this article?

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

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

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

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

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

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

シリーズ一覧

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

イントロダクション

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

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

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

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

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

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

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が解決されたら再度レンダリングが試みられます。まだ、サスペンドが発生したときに代替のビューを提供する機能が提供されます(SuspenseuseTransition)。

これらの機能を使うことで、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の現在の状態に基づく分岐を実装しています。

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>の実装は以下の通りです1

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を使うコンポーネントは、例えばこんな見た目になります。

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」ボタンを押すとusersFetcherFetcher<User[]>のインスタンスが入ってUserListがレンダリングされます。なお、UserListはサスペンドする可能性があるので、このようにSuspenseで囲んでフォールバックコンテンツ(中でサスペンドが発生したときに代わりにレンダリングされる内容)を指定しておく必要があります。

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

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

従来の方式との比較

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

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を書くで説明したようにこれはベストなステートの表現ではありませんが、今回の本質にはあまり関わりません)。ボタンがクリックされたときは、「ローディング状態をにする→非同期処理を発火→終わったら結果をステートに反映」というステップを踏みます。

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を活用する


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

uhyo
Metcha yowai software engineer
https://uhy.ooo/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした