React 18とSuspenseの基本 ─ フレームワークの選択やReact Server Componentsなど新しいベストプラクティスを学ぶ

群雄割拠のフロントエンドフレームワーク界でも、一歩抜きん出た存在として常に注目を集めてきたReact。2022年3月にリリースされたバージョン18では、Suspenseの安定化やStreaming SSRのサポートなど数々の新機能を取り入れて話題になりました。本記事では、React/TypeScriptのスペシャリストとして幅広く活躍されているuhyoさんが、現時点におけるReactのベストプラクティスについて解説します。

React 18とSuspenseの基本 ─ フレームワークの選択やReact Server Componentsなど新しいベストプラクティスを学ぶ

Reactは、Meta社により開発・公開されているJavaScript用のUIライブラリです。 大規模なWebフロントエンド開発において、UIライブラリの存在は欠かせません。現代的なUIライブラリの多くは、コンポーネントベース・宣言的UIという考え方を採用しており、その中でもReactは代表的なライブラリの1つです。

React

最初のリリースからすでに10年が経過したReactは、今となっては現代的なUIライブラリとして古参の部類に入りますが、独自の特徴を維持し続けたことにより、今なお高いプレゼンスを発揮しています。具体的には、コンポーネントをあくまで「入力と状態からレンダリング結果を返す関数」として扱うメンタルモデルを採用しています。これは他のメジャーなライブラリとはやや異なる考え方で、これによりReactは独自のパフォーマンス特性を持っています。また、JSXという拡張構文との親和性が高く、JSXをフロントエンド開発のエコシステムに普及させた立役者でもあります。

React周辺のエコシステムとしてフレームワークの開発も進んでおり、Next.jsRemixGatsbyなどがよく知られています。UI本体を司るReactに対して、これらのフレームワークはその周辺領域を補完するものです。具体的には、サーバーサイドレンダリング(SSR)やルーティング機構などを提供しますが、GatsbyなどいわゆるJAMスタックを得意とするものもあります。Reactの公式でも、実用的なアプリケーションを構築する際にはフレームワークと併用することを推奨する動きがあります。

この記事では、新しいReact 18時代におけるベストプラクティスをまとめました。皆さんの実務で役立つように、React本体の使い方だけでなく、ライブラリの選定にも踏み込んで解説します。

※本記事の記述は、記事執筆時点(2023年6月)の情報にもとづきます。

React 18時代で変わるベストプラクティス

本記事執筆時点でReactのメジャーバージョンは、2022年3月にリリースされたReact 18です。これは、React 16.8でフックがリリースされたときに匹敵する大きなバージョンアップでした。具体的には、Suspense(for data fetching)の安定化や、concurrent rendering関係の機能の実装などが行われました。

このように大きな機能追加があると、それに伴ってベストプラクティスも変化します。React 16.8がリリースされた際には、Reactコンポーネントを定義するベストな方法が、クラスコンポーネントから関数コンポーネントに切り替わりました。React 18においてもいろいろな新機能が追加されたのですから、ベストプラクティスの変化は必定です。

最新のベストプラクティスを知るためには、まずReactの新機能を知る必要があります。この記事で取り上げるReactの新機能は主に2つ、SuspenseReact Server Components(以下、RSC)です。前者はReact 18で安定化(stable化)されたもので、後者はReact Canaryの機能です。

React Canaryとは

React Canaryとは、16、17、18、……といったバージョンが付いたリリース(stableチャンネル)とは異なる、Reactの新しいリリースチャンネルです。2023年5月に次の記事で公式にアナウンスされました。

React Canaries: Enabling Incremental Feature Rollout Outside Meta – React

RSCは本記事執筆時点でまだ安定版としてリリースされていませんが、React Canaryでは使用することができます。このように、React CanaryはReactの新機能を先行して使うことができるチャンネルです。その代わり、頻繁に破壊的変更が入る可能性があります。

破壊的変更への対応は大変になるため、React Canaryを皆さんが直接利用することは普通ありません。フレームワークを介して利用し、破壊的変更はフレームワークのレイヤーで吸収することが想定されています。例えばNext.js 13.4でApp Routerという機能がstable扱いになりましたが、これもReact Canaryの機能であるRSCを利用しています。まだCanaryな機能であっても、フレームワークが頑張ることで一般ユーザーに安定した機能として提供されるのです。

React Canaryの機能第一号であるRSCについては、開発が進んでいることは数年前から明らかにされていましたが、いまでも安定版はリリースされていません。詳しくは後述しますが、RSCは大がかりな機能であるため、安定版として提供されるには多くの試行錯誤や実験、フィードバックが必要です。このフィードバックを集めるため多くのユーザーに使ってもらう必要があり、その手段がReact Canaryなのです。

そうは言っても、React Canaryに入る機能がまったく安定していないわけではありません。Meta社内である程度ブラッシュアップしてからCanaryデビューとなります。

React 18を支えるSuspense

バージョン18以降のReactにおいて基本的な概念となるのがSuspenseです。Suspenseの存在はReactアプリケーションのコンポーネント設計に影響を与えます。そのため、Suspenseを理解しておくことはReact 18のベストプラクティスを理解する上できわめて重要です。

Suspenseを使うことで、従来よりもよいコンポーネント設計ができます。加えて、React 18で追加されたConcurrent RenderingやStreaming SSRといった機能を活用するには、Suspenseが導入済みであることが前提となります。まだSuspenseを活用できていないという方は、この機会に改めてSuspenseについて学習することをおすすめします。

Suspenseの基本的な動作

Suspenseは機能の名前であると同時に、Reactから提供されているコンポーネントの名前でもあります。コンポーネントとしてのSuspenseは次のように使用することができます。

import { Suspense } from 'react';
// ...
<Suspense fallback={<div>Loading...</div>}>
  <SomeComponent />
</Suspense>

Suspenseは通常の状態では何もせず、子として渡されたコンテンツchildrenがレンダリングされます。しかし、中身のコンポーネントがサスペンドした場合、中身がレンダリングされる代わりにfallbackとして渡されたコンテンツがレンダリングされます。

サスペンドというのはSuspense用語で、簡単に言えばコンポーネントが「まだレンダリングできない」という状態に陥ることを指します。より簡単に言えば、コンポーネントが「ローディング中」である場合にサスペンドします。

例えば、サーバーからデータを読み込んで表示する責務を持つコンポーネントの場合、データの読み込みが完了するまでは「ローディング中」とするのが妥当でしょう。そのため、Suspenseコンポーネントのfallbackには、内部のコンテンツがローディング中の場合に表示する内容を与えるのが普通です。

なお、Suspenseの内部のいずれか1つのコンポーネントがサスペンドすればfallbackに切り替わります。上の例で言えば、SomeComponentだけでなくさらにその内部のコンポーネントがサスペンドした場合にもfallbackに切り替わります。この仕組みはJavaScriptのtry-catch文に似ています。try-catch文も、tryの内部奥深くで発生したエラーをキャッチすることができますね。

TanStack Queryを用いたサスペンド

上記の仕組み上、Suspenseを活用するためには、内部のコンポーネントをサスペンドさせる必要があります。これには基本的に、サスペンド機能を内蔵したデータフェッチングライブラリを使用します(前述のReact Canaryを使用している場合はuseを使うこともできます)

今回はサスペンド機能を持つライブラリとしてTanStack Query を例に用いて説明します。ここでは最小限の解説としますので、細かな使い方についてはTanStack Queryのドキュメントを参照してください。使用バージョンは@tanstack/react-query@4.29.11です。

まず下準備として、TanStack QueryでQueryClientを作成し、QueryClientProviderでアプリケーション内に提供する必要があります。このときQueryClientのオプションにsuspense: trueを渡すことで、アプリケーション全体でSuspense対応を有効にできます。

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
});

const Root = () => (
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

そして、実際にTanStack Queryを用いてデータを取得するところではuseQueryフックを用います。

import { useQuery } from '@tanstack/react-query';

const getGitHubUser = async (username: string) => {
  const response = await fetch(`https://api.github.com/users/${username}`);
  if (!response.ok) {
    throw new Error("?");
  }
  return response.json() as Promise<{
    name: string;
    followers: number;
  }>;
};

const SomeComponent = () => {
  const { data } = useQuery({
    queryKey: ["github"],
    queryFn: () => getGitHubUser("uhyo"),
  });

  return (
    <p>
      {data?.name} has {data?.followers} followers.
    </p>
  );
};

このSomeComponentは次のように使われます(再掲)

<Suspense fallback={<div>Loading...</div>}>
  <SomeComponent />
</Suspense>

ここで、useQueryは外部(上の例だとGitHub API)からデータを取得するので、データの取得が完了するまでの待ち時間が生じます。このときuseQueryの機能により、SomeComponentはサスペンドします。その間は、Suspenseに渡されたfallbackが表示されます。データの取得が完了するとSomeComponentのサスペンドが解除され、SomeComponentの中身が表示されます(正確には、SomeComponentのレンダリングが再度行われ、今度はサスペンドせずにレンダリングが完了するということです)

以上が基本的なSuspenseの利用法です。

従来の方法とはコンポーネントの責務に違い

React 18より前はSuspenseが安定版として提供されていなかったので、別の方法でデータの取得待ちを扱う必要がありました。基本的には、ローディング中かどうかを表すステートを別途持つという方法です。先ほど例に用いたTanStack QueryではuseQueryの結果としてisLoadingというプロパティが提供されているので、これを使うことでローディング中かどうかを判定できます(なお、isLoadingは次期バージョンであるv5ではisPendingという名前に変わるため注意しましょう)

const SomeComponent = () => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ["github"],
    queryFn: () => getGitHubUser("uhyo"),
  });

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error!</div>;
  }

  return (
    <p>
      {data.name} has {data.followers} followers.
    </p>
  );
};

このような従来の方法とSuspense以降の方法を比較すると、コンポーネントの責務に違いがあるのが見て取れます。具体的には、Suspense以降の方法ではローディング中の表示をコンポーネントの外側で行うのに対し、従来の方法ではコンポーネントの中で行っています。基本的にはSuspense以降の方法が、コンポーネントの責務が単純になっているため望ましいと言えます。

Suspenseは内部のコンポーネント全てを一括して取り扱えるので、内部にデータの取得を行うコンポーネントが複数存在するがローディング状態はまとめておきたいという場合には特に有用です。

エラーハンドリングにも責務の違い

ちなみに、Suspenseありとなしの場合ではエラーハンドリングの手法も変わります。今回の説明では省略していますが、Suspenseありの場合は、ErrorBoundaryというコンポーネントを用いてエラーハンドリングを行います。一方、Suspenseなしの従来の方法では、上の例のようにisErrorのようなフラグを用いてエラーハンドリングを行います。

以上のように、Suspenseありの場合はローディング中の対応やエラーハンドリングの責務が、実際にデータ取得を行うコンポーネントSomeComponentから分離されます。これは、コンポーネント内ではローディング中やエラーの場合を考慮しなくてよいことを意味します。つまり、上の例で言えば、SomeComponent内ではdataが存在しない場合を考慮する必要がないということです。これは実用上、大きなメリットです。これについては、この記事の後半のライブラリ選定のところで再度取り扱います。

サーバーとの統合を最適化するRSC

RSC(React Server Components)は、前述のとおり本記事執筆時点ではReact Canaryで利用可能です。これまでReactはフロントエンドで動作することを前提としていましたが、このRSCによってサーバーサイドにも進出することになりました。ReactのようなUIライブラリが取り組んでいる課題は、大規模なUIを秩序よく実装できるようにしつつ、ランタイムのパフォーマンスも維持することです。Reactはアプリケーションの大規模化に対応しつつパフォーマンスも求めた結果として、サーバーサイドまで巻き込んだ最適化を行うことにしたのでしょう。

RSCの仕組みを簡単に説明すると、Reactアプリケーションをサーバー側で処理される部分とクライアント側で処理される部分に分け、なるべく多くをサーバー側で処理してしまうことで、クライアント側(ユーザーのブラウザ)に送るデータ転送量や、クライアント側の処理量を減らすというものです。これによって、パフォーマンスの改善が見込めます。

従来から、いわゆるサーバーサイドレンダリング(SSR)によりCore Web Vitalsで言うところのLCP(Largest Contentful Paint)の改善がなされてきました。一方、RSCの恩恵としては、INP(Interaction to Next Paint)など、どちらかと言えば応答性の向上が期待できます。そのため、実際にはSSRとRSCを組み合わせて利用することになるでしょう。

サーバー用のコンポーネントとクライアント用のコンポーネント

ちなみに、RSCではサーバー用とクライアント用の2種類にコンポーネントを分類する必要がありますが、それぞれで利用できる機能が異なります。サーバー用のコンポーネントでは、従来のReactでは使用できなかったasync関数コンポーネントが可能です。一方、useStateuseEffectといった動的な挙動を含むフックはサーバー用コンポーネントからは使用できず、クライアント用のコンポーネントでのみ利用できます。

特に、コンポーネントのロジックを担うuseStateなどがサーバー用のコンポーネントでは使えないのは大きな制限のように思えます。しかし、よくよく考えてみればこの制限に納得できるはずです。

そもそも、我々がWebアプリケーションを作る際にJavaScriptを使用する理由は、ユーザーの行動に対して即座に反応し、素早いフィードバックを与えるためです。フィードバックを返すためにサーバーと1往復の通信を行うのは時間がかかりすぎます。ブラウザ上で動くJavaScriptを用いてアプリケーションを構築する理由はここにあります。逆に言えば、ユーザーの行動に反応するのがクライアント側コンポーネントの責務である一方、アプリケーションのそれ以外の部分はサーバー側で処理してもよいということです。

つまり、サーバー側のコンポーネントとクライアント側のコンポーネントの区別は案外明確です。JavaScriptの役割に照らして、本当にユーザーのブラウザ上で動く必要がある挙動を持つコンポーネントはクライアント側で、そうでないものはサーバー側にすればよいですね。例えばuseStateはステートを宣言するフックですが、ステートというのは、ステートの値が変わったときにコンポーネントを再レンダリングするための機能です。これは要するにサーバーとの通信を介さずに画面を変化させる機能ですから、クライアント側だからこそ意味があると言えます。

従来のReactでは素早い反応のために全てをクライアント側で実行していましたが、このように、よくよく考えてみるとあらゆるコードをクライアント側で実行する必要はありません。その揺り戻しの結果としてRSCが作られたと解釈できます。サーバー側とクライアント側の連携というのは難しいものですが、そこをうまくやってくれるのがRSCです。

Next.jsからRSCを使う

前述のように、RSCは本記事執筆時点でReact Canaryの機能ですから、フレームワークを介して使うのがよいでしょう。今後、安定版を利用可能になったとしても、自力でRSCを使うためにはそれに対応したサーバー実装を用意する必要があるため、やはりフレームワークを使用した方がお手軽です。

現在のところ、RSCに最も力を入れているのはNext.jsで、サーバーサイド(いわゆるBFFも含む)が存在するReactアプリケーションを作りたい場合に適しています。静的サイトの場合はGatsbyもRSCを用いた開発をサポートしています。

Next.jsにおいては、Next.js 13.4で安定化されたApp Routerという機能を介してRSCを使うことになります。App Router内で書かれたコンポーネントはデフォルトでサーバー用コンポーネントとなります。

// app/page.tsx

export default function TopPage() {
  return (
    <div>
      <h1>Top Page</h1>
      <OtherServerComponent /> {/* ←別のサーバー用コンポーネントを呼び出せる */}
      <ClientComponent /> {/* ←クライアント用コンポーネントも呼び出せる */}
    </div>
  )
}

この場合、TopPageや別のサーバー用コンポーネントであるOtherServerComponentはサーバー側でレンダリングが完了し、ただのHTMLとなってブラウザに送られます。つまり、仮にOtherServerComponentsが内部で100キロバイトあるライブラリを使用していたとしても、ブラウザにそのライブラリをダウンロードする必要がありません。

一方、ClientComponentのように、サーバー用コンポーネントからはクライアント用コンポーネントを使用することができます。クライアント用コンポーネントが書かれたファイルは冒頭に"use client";という宣言が必要です。クライアント用コンポーネントの場合そのレンダリングはクライアント側で行われるので、ClientComponentのソースコードがブラウザに送られます。次はクライアント用コンポーネントの例です。

// app/_components/client.tsx

"use client";
import { useState } from "react";

export function ClientComponent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

このコンポーネントではuseStateを使っていますが、これは先述のようにクライアント側でのみ意味のある機能ですから、クライアント用コンポーネント内でのみ使うことができます。サーバー用コンポーネント内でuseStateを使おうとするとエラーになります。

逆に、クライアント用コンポーネントからサーバー用コンポーネントを呼び出すことはできません。これは「クライアント用コンポーネントをブラウザ上で実行してみたら実はサーバー用コンポーネントが必要だったとわかった」という場合でも、サーバーに問い合わせるわけにいかないからです。ただし、クライアント用コンポーネントのchildrenや他のpropsにサーバー用コンポーネントを渡すことはできます。これは、クライアント用コンポーネントの内部で必要となるサーバーコンポーネント部分をあらかじめレンダリングして渡しておくイメージです。

基本的な概念はこれだけです。コンポーネントをサーバー側とクライアント側とにいい感じに区分できていれば、RSCを使えていることになります。ただし、Next.jsのApp Routerにおけるサーバー側の動作は、もう少し複雑になっています。具体的には、「場合によって、サーバー側のコンポーネントをプロダクションビルド時next build時)に先に実行しておく」という最適化がかかることがあります(最適化されなかった場合はユーザーからのアクセスがあった際にサーバー側で実行されますが、一定期間結果がキャッシュされるなどの仕組みもあります)。実際にNext.jsを使う際にはこの辺りについても詳しく調べてみましょう。

その他にReact 18で追加された主な機能

ここまで、SuspenseとRSCという2つの機能を大きく取り上げました。本記事執筆時点では、この2つが特にReactの最新機能として注目されています。ここでは、それ以外の機能にもさわりだけ触れておきます。

Streaming SSR

Streaming SSRはReact 18で追加された機能で、その名の通りSSR結果を徐々に出力する機能です。そもそもSSRは、Reactの機能として見れば「レンダリング結果としてDOMではなく文字列としてHTMLを出力する」ものです。従来は完全にレンダリングが終了したものを出力していましたが、ストリーミング化することにより、まだ完全にレンダリングが終了していない状態でも、そこまでの結果を出力することができるようになりました。SSRを行う場合の初期表示の速さに貢献します。

ちなみに、Streaming SSRはSuspenseを前提とする機能です。というのも、Streaming SSRで出力される途中結果というのは、一部のコンポーネントがサスペンドした状態だからです。アプリケーションのレンダリングを試みて一部がサスペンドしてもとりあえずそこまでの結果を出力し、その後サスペンドが解除されたら追加でその結果を出力するというのがStreaming SSRの仕組みです。

トランジション

次に、React 18ではuseTransitionstartTransitionといった機能が追加されました。これはReactのステート更新を「トランジション」として扱う機能で、トランジションとなったステート更新は優先度が低いものとして扱われます。ちなみに、トランジションもやはりSuspenseの利用が前提となる機能です。

トランジションはなかなか奥が深い機能ですが、わかりやすい恩恵として「ローディング中(サスペンド中)はそれまでの状態を画面に出したままにしておく」という処理をReact本体が自動的にやってくれることが挙げられます。トランジションがないと、「ローディング中は画面が真っ白になるので一瞬画面がちらついてしまう」といった問題に自分で対処する必要があります。

トランジションは強力な機能である一方、まだ使いこなしている人が多くありません。今後も、どちらかというとライブラリを介して使われることが多くなりそうです。

Suspenseネイティブにするベストプラクティス

ここまで、React 18時代に使われる新しい機能を説明してきました。ここからは、それらの機能をどのように使えばよいのかを解説していきます。

まず、全ての基礎となるSuspenseの使い方です。Reactの新しい機能はSuspenseを前提としたものが多いので、Suspenseに対応したコードを書かないと、この先Reactの新機能がさらに追加されてもその恩恵を受けられないことになってしまいます。未来を見据えてコードを長く保守したいのであれば、Suspenseを使うことは非常に重要です。

Suspenseネイティブなライブラリを使う

SuspenseはSuspenseコンポーネントを設置するところと、コンポーネントをサスペンドさせるところから成ります。この記事の前半で見たように、コンポーネントをサスペンドさせるのは基本的にはライブラリの役目です(前半の例ではTanStack Queryを用いて解説しました)。そうなると、ライブラリを選定する場合もSuspenseをうまくサポートしているライブラリを選ぶ必要が出てきます。「うまく」と言うのは、Suspenseに対応しているライブラリでも「Suspenseネイティブ」度には差があるからです。

広く普及しているライブラリにはそこそこの歴史があり、Suspenseの概念が広がる前から使われていたものも多くあります。 そのようなライブラリの場合、Suspense対応が後付けになっている場合があります。実は、前半に用いたTanStack Queryもそのような「後付け」の例です。後付けの場合、APIが完璧ではないことがあります。これについてもう少し見ていきましょう。

後付けのライブラリにおける非Suspenseモードの問題

この記事の前半でTanStack Queryを使用する際、次のような設定をしていました。これにより、アプリケーション全体でSuspenseモードが有効になります。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
});

一方で、個々のデータ取得部分はこのようになっていました。ここで使われているuseQueryは、Suspenseモードでも非Suspenseモードでも使えるフックです。

const SomeComponent = () => {
  const { data } = useQuery({
    queryKey: ["github"],
    queryFn: () => getGitHubUser("uhyo"),
  });

  return (
    <p>
      {data?.name} has {data?.followers} followers.
    </p>
  );
};

このコンポーネントのreturn文にあるdata使用部分に注目すると、data?.nameのようにオプショナルチェイニングが使われています。これは、dataがnullやundefinedでもエラーにならないようにするためのものです。言い換えると、useQueryを使う場合、dataがundefinedの場合の考慮が必要です。

しかし、これはおかしな話です。前述のように、Suspenseではコンポーネント内のデータがまだローディング中の場合を考慮する必要がないはずです。つまり、dataがundefinedになることはありえないはずです。それなのに、dataがundefinedの場合の考慮が必要になっているのはおかしいですね。

こうなる理由は、お察しのとおり、useQueryが非Suspenseモードでも使われるからです。非Suspenseモードではdataがundefinedになる可能性があるため、型定義上その場合の考慮が必要なのです。このように、Suspense対応が後付けになっているライブラリでは、Suspenseモードでも非Suspenseモードでも使えるようにするために、Suspenseモードの恩恵を受けられないAPI設計になってしまうことがあります。

Suspenseネイティブなライブラリであれば、そもそも非Suspenseモードがなかったり、Suspenseモードと非SuspenseモードのAPIが別々に用意されていたりします。そのようなライブラリを使うことで、Suspenseの恩恵を最大限受けられるでしょう。

Apollo Clientの実験的な試み

前述の理由から、現在のメジャーなデータ取得のライブラリはSuspenseネイティブではないものばかりですが、本記事執筆時点の情勢としては、Apollo Client の動きが注目に値します。Apollo ClientはGraphQLクライアントライブラリであり、従来Apollo Clientから提供されていたuseQueryは非SuspenseネイティブなAPIでした。

しかし最近になって、Next.js関係の文脈で@apollo/experimental-nextjs-app-supportというライブラリが出てきました。このライブラリは、名前から分かるように実験的なものですが、従来のuseQueryとは別のSuspenseネイティブなAPIとしてuseSuspenseQueryを提供しています。

他にも、Meta製のGraphQLクライアントとして知られるRelayもSuspenseネイティブなAPIとなっています。RelayはReact 18の正式リリース前からSuspenseに対応した先鋭的なライブラリですが、コンセプトも先鋭的なところがあるためか、あまり広く使われていないようです。

このように、データ取得ライブラリに関しては本記事執筆時点でRelayとApollo Clientが一歩リードしており、徐々にSuspenseネイティブの考え方が身近になってきたところです。今後、他のライブラリがSuspenseネイティブなAPIを提供してくる可能性も十分にあります。ライブラリの最新情報を追いかけて、SuspenseネイティブなAPIを提供しているライブラリを使うようにするとよいでしょう。

Suspenseネイティブなステート設計をしよう

先ほどはデータ取得ライブラリに着目しました。実際、コンポーネントがサスペンドする最大の要因はデータのローディング中なのでこれはとても妥当です。

しかし、Suspenseを意識したアプリケーション設計をすると、自然とステート管理にも影響が及ぶものです。ステート管理はReactアプリケーションの中核を担う存在です。特にRSCの時代には、サーバー側ではなくクライアント側のコンポーネントを書くのはステートを持たせるという明確な目的があってやることですから、ステート管理の重要性は一段と際立っています。

具体的には、次のようなloadingステートはSuspense時代には不要になります。ローディング中を示すステートが作られないように意識すればSuspenseネイティブなステート設計にかなり近づきます。

const [loading, setLoading] = useState(true);

常に最終結果だけを表すようにステート設計をするのが理想的です。Suspenseやトランジションといった機能がこの設計を現実的なものにしてくれます。

このようなゴールを目指すとなると、場合によってはステート管理にもライブラリを使うという判断が現実味を帯びてきます。実際、Redux などフック以前の時代から存在する古参のライブラリが現在でも一定の人気を博す一方で、Recoil に代表されるSuspenseネイティブなライブラリも登場してきています。

ステート管理の方法については多種多様な意見が存在しており、唯一の正解を見つけにくいところです。筆者のおすすめは、やはりSuspenseネイティブなライブラリを使うことです。Suspenseネイティブなライブラリは大抵、非同期なステート計算に対応しています。そうなると、従来データ取得ライブラリが担っていた役割を非同期的なステート計算の一部として、ステート管理ライブラリに担わせることも可能になります。

フレームワークをReact 18にあわせて選ぶ

本記事の冒頭でも触れたように、最近のReactはフレームワークを介して使用することが推奨されています。その主な理由は2つ考えられます。

React Canaryの機能を今すぐ使う

1つは、フレームワークを使うことでReact Canaryの機能を今すぐ使えることです。Reactの魅力の1つは非常に優れた設計思想にあり、React Canaryにあるような最新の機能は最新の研究成果が反映されたものだと言えます。特に、RSCがその着想から数年間リリースされずにまだReact Canaryに留まっていることを考えると、安定版よりもReact Canaryの方が数年先に進んでいます。

安定版との乖離が数年以上あるとなると、React開発陣は逆に安定板の更新をサボってCanaryにかまけているのではないかという印象を受けるかもしれませんが、筆者としてはそうは思いません。Reactは(トートロジーのようになりますが)安定版においては安定性にとても気を遣っているのだと考えています。React 16から17、17から18といったメジャーアップデートには、(メジャーアップデートなので)破壊的変更が含まれていました。しかし、それでもReactでは破壊的変更を最小限に抑えています。また、Metaはcodemod(破壊的変更の対応のためにコードの修正が必要な場合に自動で修正してくれるツール)の提供も得意としており、破壊的変更に対応する労力が比較的小さくてすむケースが結構あります。

破壊的変更はライブラリの大きな成長のために時には必要なものですが、破壊的変更を繰り返すライブラリは開発者からの信頼を失う傾向にあります。Reactはそれを避けるためにとても慎重に立ち回っているという印象を受けます。

このような状況において、Reactの安定性と新しい機能との両立を叶えてくれるのがフレームワークの利点です。フレームワーク固有の機能にももちろん魅力がありますが、現在ではReact Canaryへのアクセスという点もフレームワークの存在意義のひとつと言えます。

サーバーサイドに進出する

もう1つの理由は、RSCに代表されるようなReactのサーバーサイドへの進出です。Reactの本分は優れたUXを提供することにあり、そのためにはサーバーも巻き込んだアーキテクチャが必要というのが現在のReactの提案です。もちろん、そのレベルの最適化が本当に必要かどうかは場合によりますが、Reactの提案に乗るのであればサーバーは必要です。そして、このようなサーバーとクライアントの連携というのは難しいもので、一歩間違えると最適化の恩恵が容易に失われます。その難しいところをフレームワークに代わりにやってもらえば、我々は安心してReactにフルパワーを発揮させることができます。

フレームワークを使おうと言われると、ロックインの対象がひとつ増えてしまうことが心配に思えるかもしれません。しかし実際のところ、RSCには「サーバー側実装の標準化」という意味もありますから、むしろロックインを心配する方こそRSCの積極的な利用をおすすめします。

というのも、RSCの登場以前から、各フレームワークはサーバー側の実装を提供してきました。これは完全に各フレームワークの独自実装となります(例えば、Next.jsであればgetServerSidePropsなどが該当します)。RSCにより、どのフレームワークを使っていても同じようなサーバー側の実装を書けるようになります。これにより、異なるフレームワーク間でも使いまわせる実装となる可能性が高まるでしょう。

ということで、この記事の最後に、Reactと一緒に使えるフレームワークを2つ紹介します。

Next.js ─ React向けフレームワークのデファクトスタンダード

React向けフレームワークの中でも、現在のところNext.jsはデファクトスタンダード的な地位にあります。特に、React Canaryの新機能の開発に関してNext.js(というより、その開発元のVercel)は大きな役割を果たしています。Reactは最近フレームワークとの協調路線を強めていますが、その中でもNext.jsはReact本体との強調に積極的な印象を受けます。

今からNext.jsを使うのであれば、最近安定化された機能であるApp Routerを使用するのがおすすめです。RSCの例でも取り上げましたが、App Routerとは、appというディレクトリ内でファイルシステムベースのルーティングを行うことができる機能です。従来のPages Routerpagesディレクトリ以下でルーティングを行うもの)に比べても、App Routerは新機能である分、いろいろな利点があります。

特に筆者が大きな利点だと感じているのは、コロケーションの利便性の向上です。従来Pages Routerにおいてはpages以下に配置されたファイルは例外なくルート(URLを持ったページ)として扱われるため、そうではないファイルはpagesの外に配置する必要がありました。App Routerではapp内のファイルの役割が多様化しており、ルートとして扱われるファイルとそうでないファイルをapp内に混在させることができます(ただし、その裏返しとして、特別なファイル名が何個もあったり、(..)_, [ ], @など多様な記号がファイル名・ディレクトリ名として登場するなどルールが複雑化していることは否めません)

おすすめとは言え、App Routerを採用すると自動的にRSCが付いてくるので、この記事で解説したようなサーバー側とクライアント側の区別なども理解する必要が出てきます。総評としては、使いこなせるようになるまでのハードルがやや高いものの、使い方を身につければ理想形に近い状態でReactを使えるのがNext.jsの魅力です。

Remix ─ APIセットはけっこう違う対抗馬

React向けフレームワークはいろいろありますが、Next.jsの対抗馬として多くの人が思い浮かべるのは、今のところRemixではないかと思います。React Routerという有名なライブラリを発展させる形で開発されたフレームワークで、現在はShopifyの傘下に入り、こちらも大きな企業のバックアップを得ています。

RemixはNested Routesの概念をNext.jsよりも早く導入するなど、独自の路線を構築する力があるため開発力には定評があります。一方、RSCなどReact Canary絡みの動きとは距離を置いている印象がある点が心配です。Remixにおいては、RSCに頼らずとも望ましいパフォーマンスを提供できるという考えから、今のところRSCは採用されていません。そのため、フレームワークから提供されているAPIを比較すると、Remixはフック全盛時代のAPIセットで、Next.jsはポストフック時代のAPIセットという印象を受けます(ポストフック時代のAPIはまだ試行錯誤中の側面もあり、Next.jsはfetchを上書きしてしまうなどの課題も抱えていますが)

実は筆者はRemixを本格的に運用したことがないので、これ以上踏み込んだ解説はここでは避けておきます。Next.jsとRemixの比較においては「目指すゴールにそこまで大きな違いはなさそうだがAPIの形はけっこう違う」という状況なので、両者のドキュメントをよく比較して選択するとよいでしょう。

まとめ

この記事では、2023年6月現在のReactの概況を解説しました。筆者の見解では、最新鋭のReactを使いこなすにあたってSuspenseが非常に重要であり、まだ学習・採用していない方はぜひ取り組むべきだと思います。次いで重要性が高いのはRSC(React Server Components)であり、これもReactの限界を引き上げる重要な技術であると考えています。

Suspenseを使いこなすにあたっては、ライブラリの選定やステート管理のやり方などもSuspenseの考え方に合わせる必要があります。この記事ではそのための具体的な考え方を紹介しました。

また、特にRSCの存在もあり、React向けフレームワークの重要性が増しています。筆者は個人的にはどちらかというとNext.jsの方が好みですが、Reactとフレームワークの関係が強化されている現在、フレームワークがNext.jsの一強状態になってしまうのも怖いので、Remixやそれ以外のフレームワークにも注目しておきたいところです。

鈴木 僚太(SUZUKI Ryota)Twitter: @uhyo_ / GitHub: uhyo

rea1
東京大学大学院情報理工学系研究科に在学中からECMAScript・TypeScript・Reactを中心としたフロントエンド領域で活動。修士卒業後の2019年4月にLINE株式会社に新卒入社。2022年1月から株式会社バベルの技術顧問を務め、同10月には同社に入社。著書に『プロを目指す人のためのTypeScript入門』(2022年、技術評論社)。
uhyo/blog

編集:勝野 久美子(トップスタジオ)
制作:はてな編集部