Reactにおける状態管理の動向を追ってみた
こんにちは、@nerusanです。
皆さんは、状態管理ツールなどは使っておられますでしょうか。
例えば、有名なところでは、Redux, Recoilなどがあります。
今回は、Reactにおける状態管理についての動向を知ることで、なぜ、Reduxが使われるようになったのか?何をReduxなどのグローバルな状態管理ライブラリで扱えばいいのか?現状どうなっているのか?を調べたので、記事にしたいと思います!
自身の解釈なので、もしかしたら、誤ったことを言っている可能性もあるので、その際はご指摘いただければと思います m(- -)m
SPAの流行り
SPAとはSingle Page Applicationの略であり、新しいページに移動する際、サーバからページを再読み込みするのではなく、JavaScriptを使って、クライアント側のブラウザで動的にページを書き換えるアプリケーションを指します。ページごとにサーバーにリクエストを送らないため、初回表示を除き、ページ間遷移の速度が高速になるメリットがあります。
ただ、今までサーバー側で行なっていたhtmlの作成をクライアントで行うので、クライアント側の端末のスペックが低いモバイル端末等で、表示に時間がかかる可能性があります。
しかし、近年では、スマホやPCの性能が向上しているので、それはさほど問題がなく、高速ページ遷移が可能となるため、人気を博しました。
SPAの流行りによって今までサーバーサイドで行なっていた処理やデータを、クライアント側で扱うようになり、クライアント側のソースコードが複雑になりました。つまり、状態管理が複雑になったということです。
Reduxが扱われる前の状態管理
ここで、Reduxが使われるようになる以前の状態管理について見てみます。
Reduxなどの状態管理が普及する前は、addEventlitenerを利用したイベントを呼び出すことで実現していました。ただ、何も考えずに実装すると、コードが複雑になるため、あるアーキテクチャに則って作られました。
そのうちに一つ「MVC」というアーキテクチャーがあります。MVCは、モデル(M)、ビュー(V)、コントローラー(C)の頭文字を合わせたもので、簡単に言うとロジックと見た目の部分を分離し、設計することで、汎用性が上がり、変更に強く、可読性を上げようと言うことです。
今回、例として「ボタンを押すたびに1増えるカウンタ機能」をMVCに則って考えます。
ソースコードは以下に示します。
// ビジネスロジックを扱うModelクラス
class Model {
constructor() {
this.count = 0;
}
// 2. カウンタをインクリメント
increment() {
this.count++;
this.trigger();
}
// 3. データが更新されたことを知らせるイベントで発行
trigger() {
const event = new CustomEvent("count/increment", { count: this.count })
window.dispatchEvent(event);
}
}
// 一部ViewとControllerを扱うクラス
class ViewController {
constructor() {
this.model = new Model();
this.element = document.getElementById("count");
this.button = document.getElementById("button");
}
mount() {
this.render();
// クリックした時のイベント登録(インクリメントするメソッドを呼び出す)
this.button.addEventListener("click", (e) => this.onClick(e));
// インクリメントした後のイベント登録(メッセージを描画)
window.addEventListener("count/increment", (e) => this.onMessage(e));
}
render() {
this.element.innerHTML = `<p>${this.model.count}</P>`
}
// 4. インクリメントされたら、画面をレンダリング
onMessage() {
this.render();
}
// 1. ボタンをクリックされたらモデルに対してインクリメントする処理を実行
onClick(event) {
this.model.increment();
}
}
const view = new ViewController();
view.mount();
<!-- View -->
<body>
<div id="count"></div>
<button id="button">click</button>
</body>
流れは以下のようになります。
- ボタンを押すと、clickイベントが呼ばれ、インクリメントする関数を呼び出す
- データをインクリメントし、値を更新
- インクリメント完了したら、インクリメントイベントを発火
- 画面のメッセージを描画する
このようにモデルとビューがお互い双方向に、イベントを発火することにより実現していました。
一つのモデルに一つのビューであれば、まだ可読性がありますが、実際のアプリケーションでは、不特定多数のモデルとビューが互いに、イベント発火し会うことになります。
アプリケーションが大きくなるにつれて、お互いが密結合となり、複雑になるのは一目瞭然ですね。。SPAだと扱う状態が多いのでさらに複雑になることが予想できます。
単一方向データフローの登場
そこで、旧Facebook社(現Meta社)がFluxという単一方向データフローのアーキテクチャーを出しました。
ユーザ操作に対して、ActionをDidpatchし、Storeを更新し、Storeの更新を検知してViewを更新するという流れで画面を更新します。Reduxの前身のライブラリです。
この状態管理ライブラリが出たことにより、Actionを記述したり、Storeを記述したりと、ソースコードを書く量が増えました。しかし、データの更新・表示は必ず単一方向であることから、ソースコードの可読性は向上し、複雑アプリケーションでも状態の流れを追うことは簡単になりました。
そして、Fluxの後続状態管理ライブラリとして、Reduxが開発されました。Reduxでは、Reducerが追加されており、こちらで簡単なロジックを記述することができます。その他の動作としては、ほとんどFluxと同じです。
先ほどのカウンタ処理をReact、Reduxで記述してみます。ReactとReduxは別物であり、それをつなぎ合わせるためのライブラリreact-reduxを利用しております。
// アクションの定義
const COUNT_INCREMENT = "count/increment";
// リデューサーの定義
const count = (state = 0, action) => {
switch (action.type) {
case COUNT_INCREMENT: {
// 2. 値をインクリメントし、returnでストアを更新
return state + 1;
break;
}
default:
return state;
}
};
// ストアの作成
const store = Redux.createStore(Redux.combineReducers({ count }));
const Component = () => {
const count = ReactRedux.useSelector(state => state.count)
const dispatch = ReactRedux.useDispatch()
return (
<>
{/* 3. ストアが更新されると、コンポーネントが際レンダリングされ、ビューを更新 */}
<p>{count}</p>
{/* 1. ボタンクリックでActionをDispatch(発行) */}
<button onClick={() => dispatch({ type: COUNT_INCREMENT })}>
click
</button>
</>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
// ストアの変更を検知し、ビューイベント発火(レンダリング)をするために対象のコンポーネントをラッピング
<ReactRedux.Provider store={store}>
<Component />
</ReactRedux.Provider>
);
<div id="root"></div>
流れは以下のようになります。
- ボタンクリックでActionをDispatch(発行)
- 値をインクリメントし、ストアを更新
- ストアが更新されると、それを検知してビューを更新のため再レンダリング
みてわかるように、ActionをDispatchするのみで、カウンタ処理を終えた後に、見た目の表示するためのイベントを発火処理を記述していないことがわかります。ストアの変更を検知して、自動でビューを更新してくれます。なので、処理を追うことが簡単になっていることが見て取れると思います。
そのため、SPAの実装もかなり楽になり、React、ReduxによるSPA開発が主流になっていきました。
Reduxで扱うべき値
ここで、全てのステートをReduxで扱えばいいと思われた方もいると思います。
ただ、上記でも述べたようにReduxで扱うとソースコードが多くなり、パフォーマンスも悪くなります。また、ストアの値が更新されると、レンダリングが走るので、無駄なレンダリングを引き起こす可能性が多くなります。そうすると、ブラウザジング速度が遅くなり、UXが悪くなります。
なので、なんでもかんでもReduxのストアで管理すべきではありません。
では、何を扱うべきでしょうか?そのベストプラクティスは、、Redux側がベストプラクティスなどを作ってくれているのでそちらを参考にしましょう。
例えば以下のような記載があります。
React + Reduxアプリでは、グローバルステートはReduxストアに、ローカルステートはReactコンポーネント(useState)に格納する必要があります。どこに何を入れたらいいかわからない場合、どのようなデータをReduxに入れるべきかを判断するための一般的な経験則を紹介します。
- アプリケーションの他の部分がこのデータを気にするか?
- この元データを基に、さらに派生データを作成する必要があるか?
- 同じデータを複数のコンポーネントの駆動に使用しますか?
- この状態をある時点に復元できることに価値があるか(タイムトラベル・デバッグなど)?
- データをキャッシュしたいですか(つまり、再要求する代わりに、すでにそこにある状態を使用する)?
- UIコンポーネントをホットロードしている間、このデータの一貫性を維持したいですか(スワップすると内部状態を失う可能性があります)?
これは、多くの人がとりあえずでReduxを採用したものの、どのようにアプリケーションを作ればいいのかを迷っていたり、Reduxの設計意図にそぐわない使い方をしていることを示しています。実際に私が配属されたあるプロジェクトでも1ページごとに管理されておりました。
なんでもかんでもReduxで管理すると、パフォーマンスが悪くなる傾向があります。しっかりReduxのドキュメントを読み込んで、状態管理ツールの設計をして、パフォーマンスチューニングすることが大事です。
Reduxのドキュメントにも書かれているように、特に、そのコンポーエントのみの状態は、グローバルな状態管理であるReduxではなく、hooksでローカルの状態管理であるuseStateを利用します。
useStateも状態を管理できます。ただ、コンポーネントを跨ぐ場合、propsで渡す方法になります。つまり、バケツリレーで渡す必要があります。この方法であると、状態利用が子コンポーネントぐらいであれば、問題はないのですが、孫、ひ孫、ひひ孫...を跨いで利用するとなると、コンポーネントに渡すpropsが増え、複雑になります。
なので、ページ間を横断するデータ(コンポーネントを複数またがるデータ)は、Reduxなどのグローバル状態管理ライブラリで扱うと簡単になります。
また、そもそもステートで扱うべきかどうかもしっかり検討しましょう。なるべく、ReduxやuseStateを使わないことで、不要なレンダリングを省くことが大事になってきます。
見た目の制御にuseState、useRefを利用しないやり方として以下にいい記事があります。
さまざまなHooksの登場でさらに変化
状態管理では、Reduxが使われてきておりましたが、ReactのさまざまなHooksの登場でさらに状態管理の方法が変わりました。以下一部紹介します。
useContext
useStateと併用し、離れたコンポーネントに対してpropsバケツリレーをせず実現できます。つまり、Reduxでやっていることと同じことが実現できます。
このパターンの問題点はReactによる再描画が Context の単位で行われることです。何も考えずに1つのContextのみだけでやると、それを読んでいるコンポーネントでレンダリングが走ります。そうすると、レンダリングが多くなり、パフォーマンスが悪くなります。そのため、更新処理を制御する場合には、制御したい単位毎に Context を分割するか React.memo() や React.useMemo() を使い再レンダリングを細かく制御する必要があります。
useReducer
まさにReduxのローカル版である。useContextと併用すれば、グローバルに扱えるのでReduxの過善に代わりになるフックです。
以下の記事は、実際にそれを実装した人のコードが書かれています。
以下useReducerのみでローカルで利用してみました。
使い方は、reduxとほとんど同じですね!!ただ、こちらも、オブジェクトの管理の仕方により無駄なレンダリングを引き起こします。使うのであれば、ストアが持つオブジェクトを細かく分けてあげたりして管理すべきです。ただ、コードが複雑になるかなって思います。
TanStack Query, useSWR
TaStack Query, useSwrは、API通信によるデータ取得を補助するためのhooksです。データ取得が完了すると再レンダリングを行なってくれたり、手続き的に処理を記述できたり、また、キャッシュを保持してくれるため、状態管理でわざわざ扱う必要がなくなります。以下、TanStack Queryの例を示します。
const App = () => {
const { data, loading, error } = useQuery("item", fetch());
if (loading) return <div>loading...</div>
if (error) return <div>error</div?
return (
<div>{data}</div>
)
}
パフォーマンスの1番のネックになるのが、クライアント/サーバー側の通信です。Reduxなどの状態管理で全て管理すると、通信の数は減りますが、上記に述べたように、かえってレンダリング等が増えてパフォーマンスが下がる可能性があります。
そこで、キャッシュで管理する戦略がとられるようになりました。初回データ取得時に、表示と同時にキャッシュに保存しておき、2回目以降の表示時に、キャッシュのデータをユーザーにまず表示するため、ローディングがなくUXは良くなります。
キャッシュは、クライアント側に保存しますが、「状態」を扱うReduxなどとは別物であります。状態は、クライアント側で修正したり、変更することができますが、キャッシュは、サーバー側のレスポンスでのみ変更することが可能となります。
ただ、キャッシュとして扱う場合、古くなったデータを「どう扱うか」や「いつ再検証するのか」といったデータの同期について考える必要があります。TanStack Query(旧React-Query)では、キャッシュの再取得や状況に応じて自動でやってくれます。また、細かく時間を設定することもできます。
例えば、2回目の表示時にキャッシュを先にユーザに表示しますが、バックでAPI通信しており、差分があれば、表示を自動で更新してくれます。
また、React18で提供されているSuspenseを使うと宣言的に記述でき非常にコードもすっきりして良くなります。つまり、isLoding, errorなどの条件分岐の記述なくなります。errorはreact-error-boudaryの利用を想定します。
import React from 'react';
const Item = () => {
const { data } = useQuery("item", fetch());
return (
<div>{data}</div>
)
}
const App = () => {
return (
<React.Suspense fallback={<h1>Loading...</h1>}>
<Items />
</React.Suspense>
)
}
通信の最適化でさらにデータ非保持化
先ほど、パフォーマンスの1番のネックがクライアント/サーバー間通信と述べました。そこで、クライアントとサーバー間という、最も不安定な部分の距離を短くし、通信の不安定さやレイテンシを少しでも解消するアプローチが最近出てきました。
サーバーをユーザーの近くにたくさん配置するという方法があります。これは、CDNを用いてユーザーから近い場所でコンテンツを配置することで、ユーザーに素早くコンテンツを提供します。
今まで、動画コンテンツや静的コンテンツなどに関して、CDNで提供することは一般的にありましたが、実際にアプリを動かすことできませんでした。
しかし、近年、CDN上でアプリを実行できるサービスが出てきました。(動作させるサーバーをエッジサーバーとも呼ばれたりします。)これにより今までクライアント側で持っていた状態や処理をエッジサーバー側に移動し、動作させることできます。エッジサーバーを提供しているサービスで有名なところでcloudflareなどがあります。
これにより、クライアント側に状態やロジック、データなどをおかないようにする傾向になってます。また、クライアント側にhtmlを生成するSPAでなくても、あまりパフォーマンスが変わらないようになってきてます。SPAはかえって、SEOが悪くなる問題もあるので、エッジサーバー上でのSSR、IRS, SSGなどがこれからもっと普及していくのではないかと思います。
React Server Componentsの登場
React Server Componentsは、サーバとクライアントが連携してReactアプリケーションのレンダリングを行うことを可能にします。 Webページを表示する際にレンダリングされる一般的なReact要素ツリーを構成する一部のコンポーネントがサーバによってレンダリングされ、他のコンポーネントがクライアント(ブラウザ)によってレンダリングされることを可能にします。
これでさらにフロントエンドで持つ状態やロジックは絞り込むことができます。しかし、それに伴ってどのように設計するのか、テストするのかが、難しくなってくるのかなって思います。
まだまだ開発中なので、これからどんどん普及していくことが予想される技術ですね。
結局どう状態を扱う?
状態に管理について、大まかな流れを追ってきました。これを踏まえて執筆時点の今(2022/8)、どう設計をするべきなのか自身の見解を出したいと思います。
サービスの種類toB向け、toC向けで異なる考えを持っています。
共通して言えるのは、クライアント側に状態を持たせないように設計すべきということかなって思います。
1. toC向け
SEOを考慮する必要があるため、SSR, IRS, SSGを利用します。なので、ほとんど、クライアント側に状態を持たせません。その際、利用サーバーは、CDNでアプリケーションが実行できるエッジサーバーをサービスを提供しているサービスを利用することで表示パフォーマンスを落とさないようにします。
代表的なReactフレームワークは、以下のようなものがあります。
代表的なエッジサーバーは以下です。
2. toB向け
SEOをあまり必要がないため、SPAでも可能かなと考えます。ただし、クライアント側では極力グローバルな状態(Redux、Recoil)は持たせないようにします。API通信する際もキャッシュによる管理を行い、表示速度のパフォーマンスを上げます。
設計方針として以下の記事がとても参考になります。
簡単に地震の意見を添えて、まとめると以下になります。
- APIで取得した値はすべてキャッシュで管理し、状態管理ライブラリで管理しない
- 認証情報などページをまたぐ必要のあるもの、継続してユーザーに知らせ続けるものは、グローバルに状態を管理できるライブラリを利用
- そのコンポーネント内の利用での状態はuseState, useRefなどを利用
- 見た目の制御もローカルな状態管理であるuseState, useRefなどもなるべく利用しない
以上が今の自身の考えとなっています。
まとめ
いかがでしたでしょうか。SPAから始めてエッジサーバによるSSRと、どのように状態管理が使われてきたのかをみてきました。これをみてわかるように今後は、フロントエンドに状態をなるべく持たせず、パフォーマンスを上げること、a11yを上げことが求められるのかなっと思っています。
また、どんどん技術も変わってきているので、さすがフロントエンド技術は移り変わりが早いなとも感じました。
なので、フロントエンドに状態を持たせないという、流れももしかしたら変わるかもしれません。
参考
Discussion