この記事では、Next.jsとTypeScriptを使用して、ページを跨いだ状態管理ができるReduxの実装方法を説明する。

参考にした公式ドキュメント: https://redux.js.org/usage/nextjs

環境

"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",

手順

依存関係の追加

npm install @reduxjs/toolkit react-redux

ファイル構成

プロジェクトのファイル構成は、Reduxの状態管理ロジックを/libディレクトリに、Reactコンポーネントを/app/pagesディレクトリに格納。

以下の構成でファイルを作成する。

/src
 /app
  - layout.rsx ← 従来存在するレイアウトファイル
  - StoreProvider.tsx ← 作成
 /lib
  - store.ts
  - hooks.ts
  / features
   /counter
    - counterSlice.ts
 /pages
  /test
   - redux.tsx ←ストアに接続し、データの更新を行う
  - index.tsx ←トップページ(ストアに接続し、別のページで更新されたストアのデータにアクセスできるかを確認)
  - _app.tsx

lib/store.ts

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./features/counter/counterSlice";

export const store = () => {
  return configureStore({
    reducer: {
      counter: counterReducer,
    },
  });
};

// Infer the type of store
export type AppStore = ReturnType<typeof store>;
// ストア自体から `RootState` 型と `AppDispatch` 型を推測する
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];

store.tsはReduxストアを設定する。configureStoreメソッドを使用して、アプリケーションのストアを作成し、counterという名前の状態管理のためのcounterReducerを含める(後術)。

この設定により、アプリケーション全体でcounterの状態を管理できる。

lib/hooks.ts

import { useDispatch, useSelector, useStore } from "react-redux";
import type { AppDispatch, AppStore, RootState } from "./store";

// 単一の `useDispatch` と `useSelector` の代わりに、アプリ全体で使用する。
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();

hooks.tsは、ReduxのuseDispatchuseSelectoruseStoreフックをアプリケーションの型でラップし、再利用可能にする。これにより、型安全な方法でReduxの機能をReactコンポーネント内で使用できる。

lib/features/counter/counterSlice.ts

import { RootState } from "@/lib/store";
import { PayloadAction, createSlice } from "@reduxjs/toolkit";

// slice stateの型を定義する
export interface CounterState {
  value: number;
}

// 型を使って初期状態を定義する
const initialState: CounterState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // PayloadAction型を使用して、`action.payload`の内容を宣言する
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// セレクタなどの他のコードは、インポートされた `RootState` 型を使用することができる
export const selectCount = (state: RootState) => state.counter.value;

export default counterSlice.reducer;

counterSlice.tsは、カウンターの状態とロジックを管理するRedux ToolkitのcreateSliceを使用している。ここで、カウンターの値を増減させるアクションと初期状態を定義。

app/StoreProvider.tsx

"use client";

import { AppStore, store } from "@/lib/store";
import { useRef } from "react";
import { Provider } from "react-redux";

export default function StoreProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const storeRef = useRef<AppStore>();
  if (!storeRef.current) {
    // 最初のレンダリング時にストアインスタンスを作成する
    storeRef.current = store();
  }

  return <Provider store={storeRef.current}>{children}</Provider>;
}

StoreProvider.tsxは、ReduxのProviderコンポーネントを使用して、アプリケーションのトップレベルでReduxストアを提供する。これにより、アプリケーションのどのコンポーネントからもReduxストアにアクセスできるようになる。

pages/_app.tsx

import type { AppProps } from "next/app";
import StoreProvider from "@/app/StoreProvider";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <StoreProvider>
      <Component {...pageProps} />
    </StoreProvider>
  );
}

_app.tsxは、Next.jsの特別なページで、アプリケーションで使用されるすべてのページに共通のレイアウトや設定を適用する。ここでStoreProviderを使って、すべてのページでReduxストアが利用できるようにしている。

pages/test/redux.tsx

Reduxストアを使用してデータを表示し、更新する具体的なページの実装例。カウンターの値を増減させるボタンを提供。

import { decrement, increment } from "@/lib/features/counter/counterSlice";
import { useAppDispatch, useAppSelector } from "@/lib/hooks";
import { Container } from "@mui/material";
import { useRouter } from "next/router";

export default function ReduxPage() {
  const router = useRouter();
  // state` 引数はすでに `RootState` として正しく型付けされている
  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

  return (
    <Container maxWidth="sm">
      <div>
        <button
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          Increment
        </button>
        <span>{count}</span>
        <button
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          Decrement
        </button>
      </div>
      <div>
        <button onClick={() => router.push("/")}>TOP Page</button>
      </div>
    </Container>
  );
}

pages/index.tsx

カウンターの現在値を表示する。これにより、Reduxを使って状態管理が行われていることを確認できる。

import React from "react";
import { Container } from "@mui/material";
import HomeNavigation from "@/components/pages/home/HomeNavigation";
import { useAppSelector } from "@/lib/hooks";

export default function Index() {
  const count = useAppSelector((state) => state.counter.value);

  return (
    <Container maxWidth="md">
      {count}
      <HomeNavigation />
    </Container>
  );
}

カテゴリー: Next.js