Reactを取り巻く状態管理の潮流を学ぼう。HooksやServer Componentsなどの登場で何が変わるか
Reactを取り巻く状態管理のアプローチは変化を続けていますが、いま知っておくべき手法とはどのようなものでしょうか。小林 徹(@koba04)さんに、現在、そしてこの先の状態管理について執筆いただきました。
こんにちは、小林(@koba04)です。 2019年5月に『SPAにおける状態管理:関数型のアプローチも取り入れるフロントエンド系アーキテクチャの変遷』という記事を書きましたが、そこから2年以上が経過し、Reactを用いた状態管理は大きく変わりました。本記事ではReactを取り巻く状態管理の変遷について解説します。
広がるReduxの採用
Reactを用いたSingle Page Application(以下、SPA)における状態管理の手法として、Redux はデファクトと言えるほどに広く採用されるようになりました。NetflixやTwitter、Slackといった多くのアプリケーションでReduxは採用されました。 その結果、Reactを採用する場合にはReduxもともに導入されることが増えてきました。
SPAにおける状態管理:関数型のアプローチも取り入れるフロントエンド系アーキテクチャの変遷で述べた通り、Reduxは予測可能な状態管理手法を提供します。加えてDevToolsなどのエコシステムが充実していることも利点でしょう。その一方で、Reduxを利用するには、ひとつの更新処理を行うためにActionを定義し、Actionを作成する関数と、Actionをもとに状態を更新する関数をそれぞれ作成する必要があります。そのため、いわゆるボイラープレートコードと言われるような定型的なコードが多くなります。
これはFluxで提唱されたUnidirectional(単方向)なデータフローを実現するための手法です。この手法には、処理を分割しそれぞれをピュアな関数として実装することで、各関数をシンプルにしたりテスト可能な状態にするといったメリットがあります。しかしながら、これらのメリットがあることを理解していない場合には、ただ面倒なだけであると感じるのは自然なことです。
Reduxの採用が急速に拡大することで、本当に必要なのか検証されず、とりあえずReduxをReactとセットで導入するケースも増えました。その結果、Reduxのボイラープレートコードの多さに対する不満が広がっていきました。この傾向についてはState of JSの調査結果からも確認できます。
また、全ての状態をReduxに入れることで生じるデメリットについてもフォーカスされるようになりました。特にフォームのような高いインタラクティブ性が求められるようなケースでは、不必要な範囲の再レンダリングはユーザー体験にネガティブな影響を与えます。そのため、Reduxでは公式のドキュメントで全ての状態をReduxに含めるのではなく、コンポーネントのローカルな状態にすることを推奨しています。
これは全ての状態管理を集約することで、状態に対するスナップショットの取得やUndo/Redoを可能にするReduxの特徴と相反する部分があります。しかしながら、パフォーマンスとのトレードオフを考えると、コンポーネントローカルな状態を持つことは妥当でもあります。
このように、Reduxのドキュメントには多くの示唆が含まれています。Reduxは実装としては本当に小さなライブラリですが、ドキュメントはその本体の小ささからは想像もできないほどの充実度です。その内容もAPIリファレンスやチュートリアルだけでなくベストプラクティスやFAQなど、Reduxを使ってどのようにアプリケーションを作るのかを利用者に教える内容となっています。
裏を返せば、これは多くの人がとりあえずでReduxを採用したものの、どのようにアプリケーションを作ればいいのかを迷っていたり、Reduxの設計意図にそぐわない使い方をしていることを示しています。
Reduxではこうした問題を解決するため、ドキュメントを充実させるだけでなく、ベストプラクティスを実装として提供するためのツールセットとしてRedux Toolkitを公開しています。
これは公式ページに「Opinionated」と記載がある通り、Reduxの開発チームが考えるベストプラクティスを強く打ち出した形になっています。Redux Toolkitを使うことで、開発者はボイラープレートコードやディレクトリ構成に悩むことなく、アプリケーションの機能実装によりフォーカスできます。以下のRedux Toolkitを使った状態管理部分のサンプルコードをご覧ください。
import { createSlice } from '@reduxjs/toolkit' const initialState = { value: 0, } export const counterSlice = createSlice({ name: 'counter', initialState, reducers: { increment: (state) => { // Immer というライブラリが Mutable な更新処理を Imuutable な更新として処理してくれる // https://github.com/immerjs/immer state.value += 1 } }, }) // 状態を更新する Action を作成する関数 export const { increment } = counterSlice.actions // State の更新処理を行う Reducer export default counterSlice.reducer
Redux単体で使う場合に比べると必要最低限な実装になっていることがわかります。これにより、Reduxは引き続き複雑なアプリケーションや大規模なチームで規約を持って開発したい場面で採用されています。
また、より複雑な状態に対しては、XStateのようなステートマシンを用いて管理する方法も注目されています。
XStateでは状態をFinate State Machine(有限状態機械)として扱うことで、アプリケーションが取り得る状態遷移のパターンを明示できます。またXStateはビジュアライザーを提供しているため、状態遷移の可視化も可能です。
Hooksの登場
Reactにおける状態管理は2018年に発表されたHooks APIの登場により大きく変わりました。
状態管理の観点から見るとHooks APIはそれ以前と比較して新しく何かができるようになったわけではありません。しかしながら、従来のようにクラスコンポーネントに書き換えることなく、関数コンポーネントのまま手軽に状態を持つことが可能になったのです。
状態を持つための useState()
のHooksだけでなく、Reduxで使われているパターンをベースとした useReducer()
も追加されました。これにより、外部ライブラリを使わずにReactの機能だけで状態管理を完結させるケースも増えてきました。この背景には useContext()
や useRef()
といったHooksの存在もあります。これらは状態管理のためのHooksではありませんが、柔軟な状態の管理方法を提供してくれます。useContext()
はアプリケーション内の広範囲かつ、さまざまな階層で必要になるデータを扱う際に便利なHooksです。useRef()
はUIの表示には関連しない(変化してもUIを更新する必要がない)状態を保持するために利用できます。
HooksはCustom Hooksとして状態管理を抽象化する方法を提供しました。これにより、状態管理の複雑性をコンポーネントから隠蔽したり、再利用性を高めたりしています。サンプルコードをご覧ください。
const Page = props => { // Custom Hooks で抽象化している const { price, setCoupon } = usePayment(props.user); return ( <PaymentSummary price={price} onUpdateCoupon={setCoupon} /> ) }
コンポーネントツリーから独立した状態管理
Reactのみで状態管理する際にコンポーネントツリーの離れた位置で状態を共有したい場合、useState()
で作った状態を useContext()
を通じて利用可能にするパターンがあります。このパターンの問題点はReactによる再描画が Context
の単位で行われることです。そのため、更新処理を制御する場合には、制御したい単位毎に Context
を分割するか React.memo()
や useMemo()
を使い再レンダリングを細かく制御する必要があります。
Note:これを解決するために useContextSelector()
という新しいHooksも検討されています。
Facebookが実験的な取り組みとして公開したRecoilは、この問題を解決するライブラリです。
Recoilは状態を効率的にコンポーネント間で共有するための仕組みを提供し、コンポーネントツリーとは独立した形で状態に対するグラフを構成します。これにより、useContext()
を直接使った場合と違い、コンポーネントツリーとは独立して変更された部分のみ再レンダリングできます。
また、非同期処理を組み込みでサポートしています。Recoilは“Reactの中で”状態を保持することがReduxとの大きな違いです 。これにより、後述するConcurrent FeaturesのようなReactの変化にも対応がしやすくなります。公式ページのモチベーションでも以下引用のように記載されている通り、Reactに寄り添った形でデザインされています。
We want to improve this while keeping both the API and the semantics and behavior as Reactish as possible.
Concurrent Featuresによる新しいユーザー体験
詳しくは本記事で解説しませんが、Reactはv18でConcurrent Featuresとして新しいレンダリングの仕組みを活用した機能を提供予定です。現在のReactは、全ての更新処理が同じ優先度で扱われ、更新時の再レンダリングを同期的に行います。Concurrent Featuresはこの処理を優先度に基づきスケジューリングし、柔軟なユーザー体験の制御を可能にしているのです。
更新をスケジューリングして非同期に処理するためには、Reactが関連する状態を把握し制御する必要があります。そのためには、更新処理中のペンディング状態と画面に表示されているコミットされた状態の両方を保持し、一貫性を保つ必要があります。レンダリング中に参照する状態が一貫性のない状態になるとTearingと呼ばれる問題が発生するためです。
下記の図は、レンダリングが中断されることで「異なるバージョンの状態」を参照してしまうシチュエーションを示しています。この場合、状態と画面表示の一貫性がなくなってしまうのです。
Reduxをはじめとする多くのライブラリではReactの外で状態を管理しています。こうしたライブラリのために、React v18はuseSyncExternalStore()
というHooksを提供します。ただし、Reactが提供するConcurrent Featuresの機能を最大限活用するためにはReact本体で状態を保持する必要があります。そのため今後Reactにおける状態管理は、React本体で状態を保持するアプローチが増えてくることが予想されます。
状態とキャッシュ
ここまで「状態」という言葉を繰り返し使っていますが、「状態」についてもう少し分類して考えてみましょう。「状態」には、DBに格納されているような永続的に保存されるデータから、ダイアログが開いているかどうかといったUIの状態を保持するデータまで、さまざまな種類があります。ここまで、コンポーネントローカルかアプリケーショングローバルな状態なのかという分け方については言及しましたが、データの性質に応じた分類は取り上げていませんでした。
状態管理が複雑化していくなか、サーバーから取得したデータを状態ではなくキャッシュとして扱い、状態管理をシンプルにするアプローチが注目されました。そもそも、これらのデータはなぜフロントエンドで保持する必要があるのでしょう。Single Source of the Truthはサーバーサイドにあり、都度サーバー上のデータを参照・更新すればフロントエンドに保持する必要はありません。
この理由は、ブラウザとサーバー間が最もパフォーマンスのボトルネックになりやすい部分だからです。ブラウザからサーバーまでは物理的距離が遠く、ユーザー毎に経路や回線状況もさまざまです。高速で安定した回線からアクセスする人もいれば、モバイルの不安定な環境からアクセスする人もいます。加えて、これらは開発者が制御できない要素です。この課題を解決するため、サーバーから取得したデータをフロントエンド(ブラウザ上)に保持するアプローチが用いられました。
こうして取り扱われるデータは「状態」ではなく、一時的に保持している「キャッシュ」として考えることができます。そのため、その他の状態とは別に扱うことは自然な考えです。キャッシュとして扱う場合、古くなったデータを「どう扱うか」や「いつ再検証するのか」といったデータの同期について考える必要があります。
また、フロントエンドとサーバー間のデータのやり取りは、扱うドメインの違いや別の言語で実装されているなどの理由で、バラバラに扱われているケースがあります。そのためフロントエンドをTypeScriptを使って型安全に書いていたとしても、サーバーとやり取りしているデータにより予期せぬエラーが発生するケースもあります。これに対してOpenAPIやGraphQLなどを用いてデータ形式をスキーマとして定義し、スキーマを元にしたコードの自動生成により保証する方法があります。このように、サーバーが保持しているデータをフロントエンドでキャッシュとして扱う場合、「状態」として扱う場合とは異なる関心事があります。
GraphQLによる宣言的なデータ管理
キャッシュとしてのデータ管理をGraphQLを用いて扱う方法があります。そのためのライブラリとしてはRelayやApolloが挙げられます。
これらのライブラリでは必要なデータをGraphQLのクエリを使って宣言的に定義できます。データを取得するという命令的な実装はライブラリに隠蔽されており、利用者は必要なデータを宣言するだけですので、データがどのように取得されてキャッシュされるかを気にする必要はありません。また、やり取りするデータの型もGraphQLによって保証できます。
// 取得したいデータを定義 const PAYMENT_QUERY = gql` query getPaymentSummary($paymentId: ID!) { paymentSummary() { price items } } `; // Apollo を使った場合のイメージ const Page = props => { const { data, loading, error } = useQuery(PAYMENT_QUERY); if (loading) return <Spinner />; return ( <PaymentSummary summary={data.paymentSummary} /> ) }
GraphQL Code Generatorのようなツールを利用することでGraphQLからTypeScriptの型定義やデータ取得のためのReact Hooksの自動生成が可能です。このアプローチはSchema-first developmentと呼ばれます。
Apolloはローカルな状態についてもGraphQLを用いた管理機能を提供しています。
Query系ライブラリの台頭
GraphQLだけでなくさまざまな非同期処理の結果をキャッシュとして扱う方法もあります。そのためのライブラリとしてはSWRやreact-queryがあります。
これらのライブラリを使うことで、サーバーとやりとりする形式に依存することなくデータをキャッシュとして扱えます。データの取得に関してもコンポーネントに宣言的に定義できます。
// SWR を使った場合のイメージ const Page = props => { const { data, error } = useSWR('/api/paymentSummary', fetcher); if (!data && !error) return <Spinner />; return ( <PaymentSummary summary={data.paymentSummary} /> ) }
また、これらのライブラリは、キャッシュの再検証をフォーカスやネットワークの状態に応じて自動的に行ってくれます。さらに、キャッシュを更新するためのAPIも提供されており、Optimistic Updateも簡単に実現できます。Optimistic Updateとは、サーバーに対して更新処理を行う際、サーバーからのレスポンスを待つことなくUIの表示を更新する手法です。例えばTwitterのLikeボタンはこの手法で実装されており、Likeに対するレスポンスがサーバーから返ってくる前にUI上では即時反映されます。この場合サーバーへの反映が失敗した場合にはロールバックする必要がありますが、ユーザーからすればサーバーからのレスポンスを待つ必要がなくなります。これはブラウザとサーバー間の距離をUIの見せ方により縮めるテクニックのひとつです。
この流れはReduxにも影響を与えています。Reduxチームは前述したRedux Toolkitの機能としてRTK Queryをリリースしています。これはSWRやreact-queryと同様のことをReduxをベースとして行うための機能です。
これらライブラリのようにHooksを使い宣言的にデータを取得するアプローチは、React v18で計画されているSuspenseを使ったデータ取得とも非常に相性のいいものとなっています。
フロントエンドとサーバーの結合
フロントエンドでの状態の扱いについて見直される一方、そもそもフロントエンドに状態を持たないようにするアプローチもあります。
フロントエンドとサーバー間の距離を埋める方法としては、フロントエンドにキャッシュを保持する方法以外にも、サーバーをユーザーの近くにたくさん配置するという方法があります。フロントエンド~サーバーという、最も不安定な部分の距離を短くし、通信の不安定さやレイテンシを少しでも解消するアプローチです。また、サーバーを多数配置することでスケール可能になります。画像やCSS、JavaScriptのような静的ファイルをコンテンツデリバリネットワーク(CDN)に配置する方法は一般的ですが、CDN上でアプリケーションを実行するアプローチが注目されています。代表的なサービスとして下記があります。
これらは世界のどこか一箇所にあるオリジンサーバーで処理を実行するのではなく、CDNなどを用いてユーザーから近い場所(Edge)でアプリケーションを動作させます。つまり、認証やA/Bテスト、オリジンサーバーへの振り分けなどの処理をよりフロントエンドに近いEdgeで実行できるのです。これにより、これまでフロントエンドにあった処理や状態をEdgeに移すという選択肢が増えました。
他にも事前にページを生成しておきEdgeに配信することでデータを管理しないStatic Site Generation (SSG) というアプローチもあります。ただし、この方法では状態を持った変化するデータについては扱うことはできません。これに対してVercelはNext.jsにおいてIncremental Static Regeneration (ISR) という機能を発表しました。
ISRではSSGと同様に静的なページを生成しますが、指定時間経過後に裏側でページを再生成します。そのため、リアルタイム性がそこまで高くないようなページに関してはフロントエンドで状態を持たない静的なページとして作成できるようになりました。
これらはフロントエンドの状態管理とは直接関係はありませんが、これまで状態管理を必要としていた部分を置き換える可能性のある取り組みです。
先日OSSとして公開されたRemixも、Reactをサーバー上で動かし必要な部分のみをブラウザ上で動作させるようなアーキテクチャを採用しています。
Remixでは、Reactのサーバーサイドレンダリング(SSR)やHTML Formsの機能を活用することで、フロントエンドの複雑性を取り除くアプローチをとっています。サーバーサイドで全て処理するとスケーラビリティやレイテンシの問題が発生しますが、RemixはCloudflare WorkersなどのEdge上で動かすためのアダプタも提供しています。
またNext.jsもMiddlewareとして、Edge上で動かすことを意図した機能を提供しています。これにより既存のアプリケーションの延長線上として、一部のロジックをEdge上で動かすことが可能です。
このように、フロントエンドとオリジンサーバーの中間に位置するEdgeサーバーの存在は、Reactでの状態管理を考える際にも重要な存在になっています。
余談ですが、RemixやNext.jsではデータの取得などのアプリケーション共通の関心事に対して、規約をベースに export
する方法を採用しています。
Next.jsは、サーバーでランタイムに処理するものは getServerSideProps()
、ビルド時に静的に処理するものは getStaticProps()
という名前の関数を定義します。
// ビルド時に処理したい場合 export async function getStaticProps({ params }) { return { props: { data: {/* */} } } } // ランタイムで処理したい場合 export async function getServerSideProps() { return { props: { data: {/* */} } } } export default function Page({ data }) { return <View data={data} /> }
また、Remixでは loader()
や action()
という関数でデータの取得や書き込み処理を定義します。
// データの読み込みを行いたい場合 export const async loader = () => { return {/* */}; } // Form の submit を処理したい場合 export function action({ request }) { const body = await request.formData(); // ... } export default function Page({ data }) { const data = useLoaderData(); return <View data={data} /> }
これにより、開発者は決まった名前で関数を実装するだけで機能を利用可能です。
Server Componentsという発明
フロントエンドでの状態管理を考える上で注目すべきReactの新機能としてServer Componentsがあります。
Server Componentsはサーバーとフロントエンドをシームレスに結合する機能です。これまでもSSRとしてReactをサーバーサイドで動作させることは可能でしたが、Server ComponentsとSSRは大きく異なります。SSRはサーバーサイドでHTMLを生成する機能であり初期表示に対してのみ有効な機能です。一方、Server ComponentsはHTML文字列ではなくReactElementをサーバーサイドで生成します。これにより、Server Componentsは初期表示だけでなく更新時にも有効です。
また、Server Componentsの最も大きな特徴として、サーバーサイドでのみ実行される点が挙げられます。これにより Server Components内でReactElementを生成するために利用されたコードはフロントエンドには配信されません。たとえばServer Components内で利用した日付処理やMarkdownをパースするライブラリといったnpmパッケージをフロントエンドに配信する必要がなく、パフォーマンス面でも大きなメリットを享受できます。また、Server Componentsはサーバーサイドのみで動作するため、サーバーサイドにあるリソースに対して直接アクセス可能です。そのためデータをキャッシュとして管理する必要すらありません。
// Page.server.jsx import {fetch} from 'react-fetch'; import PaymentSummary from "./PaymentSummary.client"; const Page = props => { // Internal API const paymentSummary = fetch('http://localhost:3000/api/paymentSummary').json() return <PaymentSummary summary={paymentSummary} />; }
Server Componentsかどうかはファイル名によって判定されます。下記のようにコンポーネントツリーの中にServer Components(.server.jsx
)とブラウザ上で動作するコンポーネント(.client.jsx
)をシームレスに混ぜることが可能です。
// App.server.jsx import { Suspense } from "react"; import Page from "./Page.server.jsx"; import PaymentSummary from "./PaymentSummary.server.jsx"; import PaymentForm from "./PaymentForm.client.jsx"; import Spinner from "./Spinner.jsx"; export const App = ({ paymentSummary }) => ( <Page>{/* サーバーサイドで render される */} <Suspense fallback={<Spinner />}> <PaymentSummary summary={paymentSummary} />{/* サーバーサイドで render される */} </Suspense> <PaymentForm summary={paymentSummary} /> {/* ブラウザ上で render される */} </Page> )
Server Componentsは下記のような形式でサーバーからフロントエンドに送信されます。下記は React Server Components Demo のアプリケーションより取得したものですが、このデータを元にフロントエンドでReactElementとして復元されて処理されます。
M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""} M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""} S3:"react.suspense" J4:["$","ul",null,{"className":"notes-list","children":[["$","li","2",{"children":["$","@6",null,{"id":2,"title":"AAAA","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"bbbb"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"AAAA"}],["$","small",null,{"children":"11/20/21"}]]}]}]}],["$","li","1",{"children":["$","@6",null,{"id":1,"title":"Test","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"aaaaabbb"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Test"}],["$","small",null,{"children":"11/20/21"}]]}]}]}]]}] M7:{"id":"./src/NoteEditor.client.js","chunks":["vendors~client3","client3"],"name":""} J5:["$","@7",null,{"noteId":2,"initialTitle":"AAAA","initialBody":"bbbb"}]
Server Componentsは前述の通りフロントエンドとサーバーサイド間の壁を取り除きシームレスな開発体験を提供します。Server Componentsではサーバーでのみ動作するコンポーネントとフロントエンドでのみ動作するコンポーネント間でデータのやりとりが可能です。すなわち、サーバー上で取得したデータをそのままフロントエンドで動作するコンポーネントに対して渡すことが可能です。
これによりフロントエンドで管理すべき状態はさらに絞られます。また、Server ComponentsをEdge上で生成でき、フロントエンドとサーバー間の距離をより近いものにします。
Server Componentsは執筆時点(2021年12月)ではまだ実験段階の機能として位置づけられているため、今後大きく変わる可能性はあります。しかしながらNext.jsやShopifyのHydrogenなど、Server Componentsを使った実験的な取り組みは始まっています。
まとめ~フロントエンドにおける状態管理~
このように Reactを取り巻く状態管理のアプローチは変化を続けています。Reduxを中心としたフロントエンドにおける状態を予測可能な形で管理する方法は、引き続き複雑な状態を持つアプリケーションにおいては有効です。その一方で全てを状態として扱うのではなく、サーバーに保存されている情報のキャッシュとして扱うという考えに基づいたアプローチも広がっています。
また、フロントエンドとサーバーという、最も距離が遠く不安定である部分をいかに最適化するかといった観点から、フロントエンドとサーバーをシームレスに結合するような流れもあります。こうした流れのなか、フロントエンドで状態として管理すべきものはどんどん見直されています。その結果残った「本当にフロントエンドで状態として管理すべきデータ」については、HooksやConcurrent Featuresを用いることで、より良いユーザー体験を実現できるようになります。
Reactが現在取り組んでいるServer Componentsはフロントエンドとサーバーの境界をより曖昧にします。良質なユーザー体験を提供するためには、フロントエンドとサーバーでのやりとりを最適化する必要があり、Server Componentsはそのひとつの形として見ることができます。今後フロントエンドにおける状態管理は、もはやブラウザ上だけで完結せずにEdgeを含めたサーバーサイドも含めて設計することが求められるでしょう。
編集:中薗 昴