フロントエンドにおける状態管理
Redux、Flux普及前
Redux普及前は、双方向にイベントを呼び出すことで実現していた。
例えば、ボタンを押すたびに1増える機能を考える。
ソースコードは以下に示す。
// ビジネスロジックを扱うモデルクラス
class Model {
constructor() {
this.count = 0;
}
increment() {
this.count++;
this.trigger();
}
trigger() {
const event = new Event("increment", { count: this.count })
window.dispatch(event);
}
}
// ビューとコントローラを扱うクラス
class ViewController {
constructor() {
this.model = new Model();
this.element = document.getElemnentById("app");
this.button = document.getElemnentById("button");
}
mount() {
this.render();
// クリックした時のイベント登録、インクリメントするメソッドを呼び出す
this.button.addEventListerner("click", (e) => this.onClick(e));
// インクリメントした後のイベント登録、メッセージを描画
window.addEventListerner("increment", (e) => this.onMessage(e));
}
render() {
this.element.innerHTML = `<p>${this.model.count}</P>`
}
onClick(event) {
this.mode.increment();
}
}
const view = new NiewController();
view.mount();
- ボタンが押されると、addEventListenerで登録したclickイベントが呼ばれてて、インクリメントする関数を呼び出す
- データをインクリメントし更新
- インクリメントがされたら、インクリメントイベントを発火し、メッセージを描画する
このようにモデルとビューコントローラーがお互い双方向に、イベントを発火することにより実現していた。
一つのモデルに一つのビューであれば、まだ可読性がありますが、実際のアプリケーションでは、不特定多数のモデルとビューが互いに、イベント発火し会うことになります。
このやり方では、アプリケーションが大きくなるにつれて、お互いが密結合となり、複雑になります。
Reduxが解決すること
単一方向のデータフローを採用したアーキテクチャです。
ビュー側でアクションを発行するのみ、データが更新され、完了したら自動でレンダリングが走ります
先ほどの例で言うと以下に変わります。
- ボタンが押されるとアクションをディスパッチし、インクリメントする
- インクリメントされた値をストアで保存(更新)
- 更新が完了すると自動で描画
アクションを発行するだけなので、ずいぶん簡単になります。
何をReduxで管理するのか
コンポーネント間でデータを共有する必要がある場合に利用します。
単一コンポーネントでのみしか利用しないステートは、useState
を利用します。
どの値を管理するべきなのかわからなくなった場合は、Redux側がベストプラクティスなどを作ってくれているのでそちらを参考にする。
例えば以下のような記載があった。
React + Reduxアプリでは、グローバルステートはReduxストアに、ローカルステートはReactコンポーネント(useState)に格納する必要があります。どこに何を入れたらいいかわからない場合、どのようなデータをReduxに入れるべきかを判断するための一般的な経験則を紹介します。
- アプリケーションの他の部分がこのデータを気にするか?
- この元データを基に、さらに派生データを作成する必要があるか?
- 同じデータを複数のコンポーネントの駆動に使用しますか?
- この状態をある時点に復元できることに価値があるか(タイムトラベル・デバッグなど)?
- データをキャッシュしたいですか(つまり、再要求する代わりに、すでにそこにある状態を使用する)?
- UIコンポーネントをホットロードしている間、このデータの一貫性を維持したいですか(スワップすると内部状態を失う可能性があります)?
また、サイボウズのエキスパートエンジニアの方は以下のように述べています。
多くの人がとりあえずでReduxを採用したものの、どのようにアプリケーションを作ればいいのかを迷っていたり、Reduxの設計意図にそぐわない使い方をしていることを示しています。
記載がある通り、多くの場合なんでもかんでもReduxで管理しており、パフォーマンスが悪くなる傾向があります。しっかりReduxのドキュメントを読み込んで状態管理ツールの設計をして、パフォーマンスチューニングする必要がありそう。
Hooksの登場で状態管理が大きく変わった
Hooksの登場で状態管理方法はさらに大きく変わりました。
useContext
useStateと併用し、離れたコンポーネントに対してpropsバケツリレーをせず実現できる。つまり、Reduxでやっていることと同じことが実現できる。
このパターンの問題点はReactによる再描画が Context の単位で行われることです。何も考えずに1つのuseContextのみだけでやると、それを読んでいるコンポーネントでレンダリングが走ります。そうすると、レンダリングが多くなり、パフォーマンスが悪くなります。そのため、更新処理を制御する場合には、制御したい単位毎に Context を分割するか React.memo() や useMemo() を使い再レンダリングを細かく制御する必要があります。
Facebookが実験的な取り組みとして公開したRecoilは、この問題を解決するライブラリです。
参照:https://qiita.com/seira/items/fccdf4e73c59c491558d
useReducer
まさにReduxのローカル版である。useContentと併用すれば、グローバルに扱えるのでReduxの過善に代わりになる?
参考:https://qiita.com/seira/items/2fbad56e84bda885c84c
useQuery, useSWR
API通信してデータを取得するためのフック。データ取得が完了するとレンダリングをしてくれ、また、キャッシュを保持してくれるため、Reduxで扱う必要がなくなる。
APIのレスポンス結果クライアントで保持する(Reduxのストア)するのは、よくない。Single Source of the Truthはサーバーサイドにあり、都度サーバー上のデータを参照・更新すればフロントエンドに保持する必要はありません。
ただ、リクエストは非同期なので、取得したタイミングでレンダリングしたいため、Reduxのストアなどで扱っていました。Hookを利用することで、取得のタイミングでレンダリングをおこなってくれるためReduxで管理する必要がなくなりました。ただ、クライアント側で都度APIリクエストを送ると、環境や場所によって表示のレスポンスが遅くなる可能性があり、UXが良くなる。
そこで、キャッシュとして保存しておき、それを表示ユーザにまず表示する。何かデータに変更があれば、キャッシュを表示した後に表示を更新し、2回目以降の訪問時にローディング時間をほぼ0にして、UXを向上させる。(Optimistic Update)
Recoil
Recoilは状態を効率的にコンポーネント間で共有するための仕組みを提供し、コンポーネントツリーとは独立した形で状態に対するグラフを構成します。これにより、useContext() を直接使った場合と違い、コンポーネントツリーとは独立して変更された部分のみ再レンダリングでき、パフォーマンスが良くなる。
また、非同期処理を組み込みでサポートしています。Recoilは“Reactの中で”状態を保持することがReduxとの大きな違いです 。これにより、後述するConcurrent FeaturesのようなReactの変化にも対応がしやすくなります。
クライアントとサーバーの結合
フロントエンド~サーバーという、最も不安定な部分の距離を短くし、通信の不安定さやレイテンシを少しでも解消するアプローチが最近出てきました。
サーバーをユーザーの近くにたくさん配置するという方法があります。CDNなどを用いてユーザーから近い場所(Edge)でアプリケーションを動作させます。
これにより今までクライアント側で持っていた状態をエッジサーバーに置き動作させることできます。
結局どうするのか?
- APIリクエストはreact-query, useSWRを利用し、ストアで監理しない
- ページを跨ぐ最小限の情報はredux, recoilなどのストアで監理する
- 認証情報や、ページをまたいで表示する必要のあるToast、継続してユーザーに知らせ続ける必要のあるバックグラウンド処理など
- モーダルの開閉フラグ、バリデーション監理ステートなど、そのページのみのステートは、useStateを利用
なるべくフックを利用することで、状態監理のストアを使わないようにすることが大事です!
参考