環境

"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で定義)を発火する。その際、サーバーから供給された初期状態を引数に加える。
  • /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()),
    },
  };
});
カテゴリー: Next.js