Next.js で Redux を導入しているプロジェクトで、next-redux-wrapper を使用している方もいるかと思います。
今年に入って v5
から v6
にアップデートが行われましたが、だいぶ挙動が変わり Universal App での State 管理をあらためて考えるきっかけになったので、ドキュメントとして残しておきます。
細かくなった State 管理
v6
のアップデートで特筆すべきは HYDRATE
のアクションが追加され、SSR時にサーバサイドで生成された任意の State をクライアント側に移譲する処理を明示的に書く必要が出たことです (React の hydrate()
と同じことを Redux で行う感じです)。
v5
までは暗黙的に State の状態はサーバ <=> クライアント間で共有されていましたが、今回のアップデートでその処理はなくなりました。本来戻るべき所に戻ってきた感はありますが、暗黙にマージされていた時はそれはそれでコードがスッキリしていたので、個人的には悪くないなって思っていました。
Next.js のアップデートのインパクト
Next.js は今まで Universal App を React で比較的容易に構築できるという側面が大きかったですが、近年、静的化 (SSG - Static Site Generation, ISSG - Incremental SSG) に力を入れており、様々なバリエーションに対応するため next-redux-wrapper もそのコンセプトに寄せてきたと解釈しています。
どのみち、Redux を使っている以上 SSR 時の State をクライアントにマージをしなければなりませんが、こうして SSR のバリエーションが増える中、そもそも State をクライアント・サーバサイドで共有すること自体を考えることへのテーゼにも感じます。
煩雑化傾向の処理
今回のアップデートでより強く感じるようになりましたが、コードが煩雑になります。
公式のサンプル にもあるように、今までライブラリがよしなにマージしていた状態を自分たちで抽出する必要が出てきます。
また、HYDRATE
には State 全てが渡ってくるので、一部の State のみをサーバサイド・クライアントで共有したいなどある場合、HYDRATE
の処理によっては型の定義などに影響が出たりします。
下記の例では、ある任意の State ブランチで HYDRATE
を使用する場合の Reducer になります。
※ 全体のソースはこちらで展開しています
const initialCounter: CountState = { count: 0 };
export const counter = (
state = initialCounter,
action: CounterActionTypes
): CountState => {
switch (action.type) {
// サーバサイドの State をクライアントでもつかいたい場合、
// HYDRATE アクションを用いてマージしてあげる必要がある
case HYDRATE:
return { ...state, count: action.payload.counter.count };
case UPDATE_COUNT:
return { ...state, count: action.payload };
default:
return state;
}
};
このケースだと、action
での型に HYDRATE
を考慮せざるおえない処理になり、
/** action */
interface HydrateCountAction {
type: typeof HYDRATE;
payload: RootState; // <= ほしくない箇所に RootState が出てくる
}
export type CounterActionTypes =
| IncrementAction
| DecrementAction
| UpdateCountAction
| HydrateCountAction;
/** 参考) redux saga で連携 */
// action type に本来欲しくない RootState がいるため Extract を使用する必要が出てくる
const { payload }: Extract<CounterActionTypes, { type: typeof INCREMENT }> = yield take(INCREMENT);
State の設計にもよりますが、本来任意の State の型で完結したい所にそれよりも上位の RootState が登場するなど、依存性の逆転が起こってしまうケースも考えられます。
公式のように combineReducer
の前処理で分岐を設けるのも一理ありますが、そこの処理でも「じゃあマージしたい State が増えたら if文 どんどん増やすんですか?」ということは避けて通れません。
Next.js (React) での状態管理
今回両者のアップデートで Next.js でのアプリケーションは State の扱いに関して色々と考えなければならない岐路に経っているように思います。
Next.js の API郡や SWR などで見れば、そもそもグローバルオブジェクトに状態を全て突っ込んで管理して欲しいような作りになってないかなと最近は感じており、よりシンプル(ここでのシンプルとは getserverSideProps
に始まる Data Fetch や Stale-While-Revalidate
を利用したデータのキャッシュなどを指します)に状態を扱えるように考える方向になっているように思います。
つまり、一度取得した情報を一つのオブジェクトに格納して使い回すだけではなく、Data Fetch, キャッシュも含めてより素直な形でUI上で扱う情報を処理して行きましょうということです。
更に、React が Hooks や Context API を提供していることもあり、「React の状態管理するなら Redux」のような脳死に近い形でプロジェクト導入するということは「まった」をかけたほうが良さそうです(Recoil もどこまで伸びるか気になりますね)。
シンプルに考えていきたい
フロントエンド・バックエンドが分離したアプリケーション構成の開発が盛んになってそこそこの年月が経っていますが、フロントエンドは SPA のみならず、Universal App、SSG、JAMStack など多様かつそのプロダクトの特性に於いて適切な実装をしなければならないケースが増えました。
SPA が非常に流行った時期もありますが、最近では SSG + CDN を使用した静的ファイルの配信のほうがやっぱり良いよね。みたいな傾向もあり、開発者はより広域な領域をカバーしないといけない状況が続くかと思います。
ただ一方で、React などの UIライブラリ、Next.js を始めとする Universal App や Data Fetch の技術の進展で先の Redux などの状態管理のように、「難しいことを難しく実装する必要が無くなってきた」 のも事実です。
なにか、今回のことを受けて「Redux を扱うべきケースはこれ」や「Redux 使わないケースはこれ」という解をアウトプットしたかったわけではありませんが、こう状態管理にはおそらく最適解というのはなく、一つ一つプロダクトの仕様と向き合って議論して方針を決めていける開発ができると良いなと思いここに書き残しておきます。