35
16

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設計論 (2) useTransitionを活用する

Last updated at Posted at 2020-03-29

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

シリーズ一覧

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をこのように変更します。

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

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

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

useTransitionにオプションとして渡したtimeoutMsは、この「isPendingtrueの状態」の最大持続時間を表します。この時間が過ぎても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で設定した時間を超えない限り、Suspensefallbackで指定した内容は表示されなくなります。useTransitionをきちんと使っている限りは、Suspensefallbackはいわば最終防衛ラインのような扱いになり、高頻度でユーザーが目にするものではなくなります。

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が何を解決するか

35
16
0

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
35
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?