😎

React v18のSuspenseで開発はどう変わるのか

2022/12/14に公開

こんにちは

これはEventHub Advent Calendar 2021 - Adventarの14日目の記事です。
https://adventar.org/calendars/7928

昨日はスーさんのたった2ヶ月でウェビナー経由の商談化率を25%超にしたウェビナー運営の秘訣とは?でした。
https://note.com/ihciuuy/n/nce05d8571b8a

今年の五月からEventHubにて業務委託として開発をお手伝いさせていただいているエンジニアの白井です。EventHubでアドベントカレンダーをやるとのことで僭越ながら自分も記事を投稿させていただくことにしました!

React v18のSuspenseで開発はどう変わるのか

現在自分の関わっているフロントエンド開発でもReactを使用しているのですが、皆さんは今年の春に正式リリースされたReact v18はもう使用されていますでしょうか?

React v18では、並行処理機能が追加され、UI更新の自動バッチング、startTransitionなどの新しいAPI、非同期所をより宣言的に書くことができるSuspenseが正式リリースされました。リリースから時間も経ちましたので、既にご活用されている方もいらっしゃるかと思います。

今回はその中でも特に開発体験への影響が大きいSuspenseを取り上げて、何が改善されるのかについて自分なりまとめていこうと思います。

Suspenseとは

Suspenseがどんな機能かを一言でいうと、コンポーネントが非同期処理でのデータ取得などでレンダリングに必要なものが揃っていない場合に「ローディング中なのでまだレンダリングできない」という状態を宣言的に書くことができるというものになります。

// ローディング中に表示するUIをfallbackに指定する
const App = () => {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
    // 非同期で取得するデータを使用するコンポーネント
      <UserProfile />
    </Suspense>
  );
}

単にその一言だけだと、コードを簡単に美しくかけるようになるぐらいに聞こえてしまいますが、もちろんそれだけではありません。実際にSuspenseがどんな問題を解決するのか、なぜその問題を解決する価値があるのか、既存のソリューションとどう違うのかを順を追ってみていきましょう。

Suspense以前の非同期処理

React v18以前では、非同期処理でAPIからデータを取得してコンポーネントをレンダリングする場合は、多くの場合は以下のような二通りのアプローチでデータ取得を実装してきたはずです。

Fetch-on-renderパターン

Reactに備わっている機能を使って一番素直にコードを書くと以下のような形になるかと思います。

コンポーネントがレンダリングされた後にuseEffectやライフサイクルメソッドを使用して、そのコンポーネントに必要なデータを取得して再度レンダリングします。コンポーネントがレンダリングされるまでデータ取得が行われないためFetch-on-renderパターンと呼ばれています。

const ProfilePage: React.VFC = () => {
  const [user, setUser] = useState()
  useEffect(() => {
    try {
      const data = fetchUser();
      setUser(data);
    } catch (e) {
      setError(e);
    }
  }, []);

  if (error) {
    // エラー処理
    return <p>Error</p>;
  }

  if (!user) {
    // ローディング中はローダーを表示する
    return <p>Loading profile...</p>;
  }

  return (
    <div>{user.name}</div>
  );
}

これはシンプルで分かりやすいコードですが、非同期処理を行うコンポーネントに階層構造が存在する場合、しばしば「ウォータフォール」という問題を起こすことで知られています。

例えば以下のような場合です。上記のコードに加えて、Userコンポーネントの中でUserのProfile情報を取得しているとしましょう。

// 親コンポーネント
const ProfilePage: React.VFC = () => {
  const [user, setUser] = useState();
  const [error, setError] = useState();
  useEffect(() => {
    try {
      const data = fetchUser();
      setUser(data);
    } catch (e) {
      setError(e);
    }
  }, []);

  if (error) {
    // エラー処理
    return <p>Error</p>;
  }

  if (!user) {
    // ローディング中はローダーを表示する
    return <p>Loading profile...</p>;
  }

  return (
    <div>
      <div>{user.name}</div>
      // 非同期処理を行う子コンポーネントのレンダリング
      <UserTimeline />
    </div>
  );
}

// 子コンポーネント
const UserTimeline: React.VFC = () => {
  const [timeline, setTimeline] = useState();
  const [error, setError] = useState();
  // 子コンポーネントでもデータ取得を行う
  useEffect(() => {
    try {
      const data = fetchUserTimeline();
      setTimeline(data);
    } catch (e) {
      setError(e);
    }
  }, []);

  if (error) {
    // エラー処理
    return <p>Error</p>;
  }

  if (!user) {
    // ローディング中はローダーを表示する
    return <p>Loading timeline...</p>;
  }

  return (
    <div>
      {timeline.map(t => (
        <div>...</div>
      ))
    </div>
  );
}

一見するとデータ取得ロジックとそれを必要とするコンポーネントを同じ箇所に書いているので、コードとしては読みやすくもあるのですが、ユーザーの情報の取得に3秒かかるならタイムライン情報の取得を開始するのは3秒後になります。これは「ウォーターフォール」と呼ばれるもので、本来並列で実行できるものが意図せず順番に並んでしまっています。

Fetch-then-renderパターン

「ウォータフォール」を解決する一つのアプローチがFetch−then-renderパターンです。
これは、そのページに必要なデータの取得を一箇所にまとめ、できるだけ早くfetchして、その後にレンダリングをするパターンです。

const fetchData = () => {
  return Promise.all([
           fetchUser(),
           fetchUserProfile();
         ]);
};
const promise = fetchData();

// 親コンポーネント
const ProfilePage = () => {
  const [data, setData] = useState();
  const [error, setError] = useState();

  useEffect(() => {
    const fetch = async (): Promise<void> => {
      try {
        const [user, timeline] = await promise;
        setData({ user, timeline })
      } catch (e) {
        setError(e);
      }
    }
    void fetch();
  }, []);

  if (error) {
    return <p>Error</p>;
  }

  if (!data) {
    return <p>Loading ...</p>;
  }

  const { user, timeline } = data;

  return (
    <div>
      <div>{user.name}</div>
      <UserTimeline timeline={timeline} />
    </div>
  );
}

// 子コンポーネント
const UserTimeline: React.VFC = ({ timeline }) => {
  // データ取得ロジックは親コンポーネントへ
 return (
   <div>
     {timeline.map(t => (
      <div>...</div>
     ))
   </div>
 );
}

親コンポーネントでPromise.allを使用してデータ取得をすべて並列で行うことで、ネットワークの「ウォーターフォール」は解決しました。

しかしPromise.all()を使ってすべてのデータが戻ってくるのを待つので、タイムラインが取得されるまではユーザーの情報をレンダリングすることができません。この場合、Promise.allに含まれるいずれかのデータ取得で著しく時間がかかる場合、全体データ取得の完了がそれに引きずられてしまうという別の問題が発生してしまいます。

もちろん、Promise.all()の呼び出しを削除し、両方のPromiseを別々に待機させることはできますが、取得するデータやコンポーネントの階層構造が複雑になるほどそれらの管理は難しくなっていきます。

結果として信頼性の高いコンポーネントを書くことは困難です。そのため多くのケースでは画面のすべてのデータをフェッチしてからレンダリングする方が、より実用的な選択肢であると考えられます。

Suspenseを使った非同期処理

Render-as-you-fetch

上記の2つのパターンの問題をSuspenseを使って解決するのがRender−as-you-fetchパターンです。

Render-then-fetchパターンと同じように、データ取得のロジックは一つにまとめますが、それをPromise.allですべての完了を待つのではなく、ページ読み込み前に取得を非同期で開始し、それぞれのコンポーネントにPromiseを渡していきます。

Suspenseで囲ったコンポーネントにPromiseを渡し、Promiseが待機中であればそのPromiseをthrowすることで、そのPromiseをキャッチしたSuspenseがfallbackに指定したUIを自動的に表示してくれます。
もちろんPromiseが完了された時には自動的にfallbackUIから本来のUIに切り替わります。
エラーはSuspenseの上位に配置したErrorBoudaryにキャッチさせることになります。

// Suspenseのために用意したオブジェクトを返す。
//  ページに必要なデータの取得をページレンダリング前に行う。
const resource = fetchProfileData();

const ProfilePage = () => {
  return (
    <div>
        // Promiseがrejectされた場合はErrorBoundaryがキャッチする。
      <ErrorBoundary>
          // fallbackにはローディング時に表示したい内容を指定する
        <Suspense fallback={<p>Loading profile...</p>}>
          <UserProfile user={resource.user} />
        </Suspense>
      <ErrorBoundary/>
      <ErrorBoundary>
         // Suspenseは非同期処理の境界を定義している。
	// Suspense毎にそれぞれが並行に読み込まれる。
        <Suspense fallback={<p>Loading timeline...</p>}>
          <UserTimeline timeline={resource.timeline} />
        </Suspense>
      <ErrorBoundary/>
    </div>
  );
}

// Promiseはcontextを使ったりpropsとして渡して取り回していく
const UserProfile = ({ resource }) => {
  // readメソッドでpromiseが完了されていればuserのデータを返す、
  // rejectedであればerrorをthrow、待機中であればpromiseをthrowする。
  // この時点ではデータが読み込まれているとは限らない。
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function UserTimeline = ({ resource }) => {
  const timeline = resource.timeline.read();
  return (
    <div>
      {timeline.map(t => (
        <div>...</div>
      ))
    </div>
  );
}

それぞれのPromiseを個別にコンポーネントに渡して取り回すので、データの読み込みとレンダリングはすべて並行して行われます。これによりネットワークのウォータフォール問題や、Promise.allを使用したときに他の重たい処理に全体が引きづられてしまうこともなくなりました。

また、Suspenseを使用することでuseStateやuseEffectなどのhooks、データが取得できていない時のローディング表示が必要なくなり、非同期処理をより宣言的に書けるようになりました。
if文とか書かなくて良いのはシンプルに嬉しいですね!

SuspenseはuseTransitionを使うことで威力を発揮する

同じくReact v18から正式リリースされたuseTransitionのことについても少しだけ触れておくと、こいつを使うことで、今のページを表示しながら裏側で次のページを用意するといったことができるようになります。

useTransitionは、処理の実行状態を表すbooleanと、トランザクションを開始するための関数を返します。
startTransitionでsetState関数を囲うことで処理の優先度を下げることが可能です。

  const [isPending, startTransition] = useTransition()

  // setXXXによって起こるUIの変更を遅延させる。
  startTransition(() => {
      setXXX(values)
  })

これがどんな場面で役に立つか見ていきましょう。

useTransitionなしの場合

Userの一覧を表示するページがあったとします。
ページ番号をuseStateで管理し、ユーザーの一覧を取得します。

const App = () => {
  const [page, setPage] = useState(0);
  const resource = fetchUsers(page);
  const nextPage = () => { setPage((prevState) => prevState + 1) }

  return (
    <div>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading...</p>}>
          <UserList user={resource} nextPage={nextPage} />
        </Suspense>
      <ErrorBoundary/>
    </div>
  );
}

const UserList = ({ nextPage, resource }) => {
  const userList = resource.userList.read();

  return (
    <div>
      {userList.map((user) => (...))}
      <button onClick={nextPage}>次のページへ</button>
    </div>
  );
}

上記のようなケースではsuspenseを使用しているので、「次のページへ」のボタンをクリックするたび、fallbackに指定したUIが一時的に表示されます。
初回のページ表示時には、表示するものが何もないので、fallbackUIが表示されるのは理にかなっていると言えるのですが、次のページに遷移した場合にもfallbackUIが表示され、画面全体が切り替わってしまうのは無駄なチラツキが発生していると言えます。

そこでuseTransitionの出番です。

useTransitionありの場合

useTransitionを使用して、次のページへ遷移するUIの変更を優先度の低い処理としてマークしました。

const App = () => {
  const [page, setPage] = useState(0);
  const resource = fetchUsers(page);
  const [isPending, startTransition] = useTransition();
  const nextPage = () => {
      // setPageを優先度の低い処理としてマークする
    startTransition(() => setPage((prevState) => prevState + 1))
  }

  return (
    <div>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading...</p>}>
          <UserList
	    user={resource}
	    nextPage={nextPage}
	    isLoadingNextPage={isPending}
	  />
        </Suspense>
      <ErrorBoundary/>
    </div>
  );
}

const UserList = ({ nextPage, resource, isLoadingNextPage }) => {
  const userList = resource.userList.read();

  return (
    <div>
      {userList.map((user) => (...))}
      // 次のページを読み込んでいる間はローディング表示を行う
      <button
        onClick={nextPage}
	disabled={isLoadingNextPage}
      >
        {isLoadingNextPage ? "...Loading" : "次のページへ"}
      </button>
    </div>
  );
}

useTransitionを使うことで、startTransitionで囲われたステート更新によって引き起こされた再レンダリングがSuspendした場合、fallbackUIを表示する代わりに今の状態を表示し続けます。

また、UIの状態変更が遅延されていることを表すisPendingを使用して、ユーザーに細かいフィードバックを行うことも可能になりました。

次のUIの準備ができた(suspendが終わった)タイミングで、UIは切り替わります。不要な画面のチラツキがなくなるわけですね。

終わりに

今回は、React v18のSuspenseで開発はどう変わるのかについて書かせていただきました。
フロントエンドの進化は凄まじいですね。

周辺のエコシステムもReact v18の機能に対応しつつあるので、それらをフル活用して開発を進めていくのが直近の楽しみだったりします!

EventHubでは、シリーズAの資金調達を受けて、採用活動を強化しています。
https://eventhub.notion.site/eventhub/EventHub-a35d5e4b2ccd4b2784f2e4483356d90f

次の15日目の記事はAya Tamogamiさんです!
https://adventar.org/calendars/7928

Discussion