32
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-29

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

シリーズ一覧

SuspenseuseTransitionが何を解決するか

前回までは、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はこんな感じで定義できます。

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の実装はこんな感じになりますね。

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のデータをロード中の段階」は、PageAisLoadingステートがtrueになり、RootfetchUsers()の結果を待っている段階として現れます。そして、「ロードが終わって画面Bをレンダリングする段階」はRootsetStateでステートを変更して画面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はこのように書き換えられるでしょう。

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コンポーネントはこのようになります。

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>
  );
};

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

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

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

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

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) コンポーネント設計にサスペンドを組み込む

32
18
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?