環境
"typescript": "^5"
"next": "14.0.3",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-hook-form": "^7.48.2",
"react-redux": "^9.1.0",
"@reduxjs/toolkit": "^2.2.3",
注意事項
(1)Next.jsアプリケーションにおいて、SSR(サーバーサイド)とクライアントサイドでストアを共有するにあたり、Redux公式ドキュメントで「next-redux-wrapper
」の使用が紹介されているが、2024年4月12日時点で1年以上コミットされていないことと、Stack OverflowやGitHub issueにおいて、Next.js14で障害報告などが見受けられたため、使用しない。
(2)サーバーとクライアントは、1つのストアを共有しているわけではなく、それぞれ別のストアを使っている。SSRでストアにデータを保存し、その状態をPageコンポーネントのクライアントに渡し、クライアントでストアを初期化する際にデータを同期する。以下のようなイメージになる。
実装
前回の記事『Next.js×TypeScriptで「Redux」を使用する方法』で解説した、カウンターのReduxをそのまま使用する。今回は、SSR時にカウンターを加算し、その状態がクライアントで同期できるかを検証する。
ファイル構成は前回と同じ
SSRとクライアントでストアの状態を同期する上で、特に重要になるのが以下のファイル群。
/src
/app
- StoreProvider.tsx
/lib
- store.ts
/pages
/test
- redux.tsx
- _app.tsx
/lib/store.ts
- ストアの初期化時、サーバーから供給された初期状態を引数で受け取り、そのデータをストアの初期値とする。
/app/StoreProvider.tsx
- クライアントでReduxを利用するための初期化処理(
/lib/store.ts
で定義)を発火する。その際、サーバーから供給された初期状態を引数に加える。
- クライアントでReduxを利用するための初期化処理(
/pages/_app.tsx
- サーバーから供給された`pageProps`を受け取り、JSON 文字列をパースして JavaScript オブジェクトに変換する。そして、StoreProviderに対してpropsでパースしたデータを渡す。
/pages/test/redux.ts
- SSRでストアを作成、更新し、クライアントにpropsで渡す。
/lib/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "@/lib/features/counter/counterSlice";
import searchDataSlice from "@/lib/features/counter/searchDataSlice";
// ReduxRootState 型定義
export type ReduxRootState = {
searchData: ReturnType<typeof searchDataSlice>;
counter: ReturnType<typeof counterReducer>;
};
// ストアの初期化
export const initializeStore = (preloadedState?: ReduxRootState) => {
return configureStore({
reducer: {
counter: counterReducer,
searchData: searchDataSlice,
},
// 初期状態として引数をストアに渡す
preloadedState,
});
};
export type AppStore = ReturnType<typeof initializeStore>;
export type AppDispatch = AppStore["dispatch"];
/app/StoreProvider.tsx
import { AppStore, ReduxRootState, initializeStore } from "@/lib/store";
import { useRef } from "react";
import { Provider } from "react-redux";
// StoreProviderの引数型
interface StoreProviderProps {
initialReduxState?: ReduxRootState;
children: React.ReactNode;
}
/**
* クライアントでReduxを利用するためのProvider
* @param initialReduxState SSRでストアにデータを保存した際の初期状態
* @returns JSX.Element
*/
export default function StoreProvider({
initialReduxState,
children,
}: StoreProviderProps) {
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// 最初のレンダリング時にストアインスタンスを作成する
storeRef.current = initializeStore(initialReduxState);
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
/pages/_app.tsx
import type { AppProps } from "next/app";
import StoreProvider from "@/app/StoreProvider";
export default function App({ Component, pageProps }: AppProps) {
// クライアント側で Redux ストアを初期化する前に、JSON 文字列をパースして JavaScript オブジェクトに変換
const preloadedState = JSON.parse(pageProps.initialReduxState || "{}");
return (
<StoreProvider initialReduxState={preloadedState}>
<Component {...pageProps} />
</StoreProvider>
);
}
/pages/test/redux.ts
import React from "react";
import { withCommonServerSideProps } from "@/hooks/server/withCommonServerSideProps";
import { useAppSelector } from "@/lib/hooks";
import { AppDataPropType } from "@/types/CommonType";
import { initializeStore } from "@/lib/store";
import { increment } from "@/lib/features/counter/counterSlice";
export default function TestReduxPage({ ...props }: AppDataPropType) {
// ストアからデータを取得
const count = useAppSelector((state) => state.counter.value);
return <div>{count}</div>;
}
export const getServerSideProps = withCommonServerSideProps(async (context) => {
// ストアの初期化
const store = initializeStore();
// データをストアにディスパッチ
store.dispatch(increment());
return {
props: {
// JSON文字列として渡す
initialReduxState: JSON.stringify(store.getState()),
},
};
});
コメント