🛠️

Next.jsにReduxを実装してみて

2021/01/14に公開4

はじめに

新しい Next.js のプロジェクトに、ログイン情報など page 層をまたぐ値を格納する Store を導入したく、Redux を触ってみた
間違いやもっといい方法がある場合はどしどしご指摘くださいませ

記事のターゲット

  • Redux を導入しようとしてる人
  • 基本は page でステート管理するけどグローバルな情報だけ store を使いたい(ライトに redux 使いたい)人

概要

ざっくりイメージ

  • @reduxjs/toolkit で state の管理
  • react-redux で react と bind してる

環境

Redux(+localStorage とバインドさせるために)を入れるためにインストールしたもの

  1. yarn add @reduxjs/toolkit redux-persist react-redux
  2. yarn add -D @types/react-redux
package.json
{
  "dependencies": {
    "@reduxjs/toolkit": "1.5.0",
    "next": "10.0.3",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "react-hook-form": "6.14.0",
    "react-redux": "7.2.2",
    "redux-persist": "6.0.0"
  },
  "devDependencies": {
    "@types/react-redux": "7.1.15",
  }
}

redux-persistの最新コミットが結構古くて若干怖いけど star 数も多いし、Next.js の examplesもあったので導入。いざとなれば自作するくらいのお気持ち。

Redux のドキュメントを見ると Redux Toolkit イイヨ!ってお勧めされてたから Redux Toolkit を使うことにした

Whether you're a brand new Redux user setting up your first project, or an experienced user who wants to simplify an existing application, Redux Toolkit can help you make your Redux code better.

store(Redux) のディレクトリ構成(参考)

  • redux-way
  • ducks
  • re-ducks

があるらしい
https://superhahnah.com/redux-directory-petterns/

Redux Toolkit 使うのでそもそもコードの記述量が減るのとあくまでログイン情報のみを store で管理する予定なのでstore.tsの 1 ファイルでもいいかもと思った(store はこれまでもこれからも拡大しないよの意を 1 ファイルであることで表す)

いざ実装

slice を作ります

src/store/user/index.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export type User = {
  name: string | null
  age: number | null
  email: string | null
  token: string | null
  history: string[]
}

export type UserState = {
  user: User
}

export type UpdateUserPayload = User
export type AddHistoryPayload = string

const initialState: UserState = {
  user: {
    name: null,
    age: null,
    email: null,
    token: null,
    history: [],
  },
}

export const userSlice = createSlice({
  name: 'user',
  initialState,
  // HACK: reducerは肥大化したらファイル分けたくなるかも
  reducers: {
    updateUser(state, action: PayloadAction<UpdateUserPayload>) {
      state.user = action.payload
    },
    addHistory(state, action: PayloadAction<AddHistoryPayload>) {
      state.user.history.push(action.payload)
    },
    reset(): UserState {
      return initialState
    },
  },
})

store 作ります

ここで persist の設定もしちゃう
Redux Toolkit のドキュメントを大いに参考にさせていただく
https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist

src/store/index.ts
import {
  configureStore,
  getDefaultMiddleware,
  combineReducers,
  EnhancedStore,
} from '@reduxjs/toolkit'
import { userSlice } from 'store/user'
import {
  persistReducer,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from 'redux-persist'
import createWebStorage from 'redux-persist/lib/storage/createWebStorage'

// HACK: `redux-persist failed to create sync storage. falling back to noop storage.`の対応
// https://github.com/vercel/next.js/discussions/15687#discussioncomment-45319
const createNoopStorage = () => {
  return {
    getItem(_key) {
      return Promise.resolve(null)
    },
    setItem(_key, value) {
      return Promise.resolve(value)
    },
    removeItem(_key) {
      return Promise.resolve()
    },
  }
}
const storage =
  typeof window !== 'undefined'
    ? createWebStorage('local')
    : createNoopStorage()

const rootReducer = combineReducers({
  user: userSlice.reducer,
})

export type RootState = ReturnType<typeof rootReducer>

const persistConfig = {
  key: 'p-next-test',
  version: 1,
  storage,
}
const persistedReducer = persistReducer(persistConfig, rootReducer)

export const useStore = (): EnhancedStore => {
  return configureStore({
    reducer: persistedReducer,
    middleware: getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
  })
}

persistConfig を作る時に storage を入れるんだけども redux-persist の v6 だと yarn dev した際にredux-persist failed to create sync storage. falling back to noop storage.って言われる
一時的にはこの対応で文句は言われなくなる

_app.tsx で store を呼びます

src/pages/_app.tsx
import { AppProps } from 'next/app'
import { Provider } from 'react-redux'
import { persistStore } from 'redux-persist'
import { PersistGate } from 'redux-persist/integration/react'
import { useStore } from 'store'

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  const store = useStore()
  const persistor = persistStore(store)

  return (
    <Provider store={store}>
      <PersistGate persistor={persistor}>
        <Component {...pageProps} />
      </PersistGate>
    </Provider>
  )
}

export default MyApp

@reduxjs/toolkit が Provider なるもの用意してくれてるのかと思えばそうではなく react-redux が必要なのがこんがらがりポイント

store に値を格納したり、取得したり

src/pages/index.tsx
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from 'store'
import { userSlice } from 'store/user'


const StorePage = (): JSX.Element => {
  const dispatch = useDispatch()
  const user = useSelector((state: RootState) => state.user)

  const handleConfirm = () => {
    // eslint-disable-next-line
    console.log(user)
  }
  const handleUpdate = () => {
    dispatch(
      userSlice.actions.updateUser({
        name: 'name',
        age: 28,
        email: 'email',
        token: 'token',
        history: [],
      })
    )
  }
  const handleReset = () => {
    dispatch(userSlice.actions.reset())
  }
  const handleAddHistory = () => {
    dispatch(userSlice.actions.addHistory('push'))
  }

  return (
    <div>
      <h1>storeの動作確認</h1>
      <button type="button" onClick={handleConfirm}>
        確認
      </button>
      <button type="button" onClick={handleUpdate}>
        update
      </button>
      <button type="button" onClick={handleReset}>
        reset
      </button>
      <button type="button" onClick={handleAddHistory}>
        addHistory
      </button>
    </div>
  )
}

export default StorePage

react-redux の useSelector で state を取得して、useDispatch で action を呼び出す感じ

GitHubで編集を提案

Discussion

wintyowintyo

大変参考になりました!
こちらを見ながら作っていたのですが、上手くstoreが更新されない時があり、Providerにstoreを入れるやり方があっていないかもしれないです。

こちらのドキュメントを見た感じ、configureStoreは新しいstoreを作るようなので、MyApp内で呼び出すと毎回新しいstoreを作ってしまっているのかなと思いました。
https://redux-toolkit.js.org/tutorials/quick-start#create-a-redux-store

なので外側でstoreを作成して、それをProviderに渡すのが正しいかもです。僕はこれで更新されない問題は解消されました。

src/pages/_app.tsx
 // useStoreというよりcreateStoreかも。あるいは公式のように最初からstoreをimportすると良いかも
+const store = useStore()
+const persistor = persistStore(store)
 const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
-  const store = useStore()
-  const persistor = persistStore(store)

   return (
     <Provider store={store}>
       <PersistGate persistor={persistor}>
         <Component {...pageProps} />
       </PersistGate>
     </Provider>
   )
 }
nus3nus3

ありがとうございます!
内容確認して、記事の修正しまっす!

nus3nus3

ちなみに

上手くstoreが更新されない時が時があり

こちら、どういった時にstoreが更新されませんでしたでしょうか?
手元で再現できず・・

基本的な実装はNext.jsのexamplesを参考にしてます!
https://github.com/vercel/next.js/tree/canary/examples/with-redux-persist

もしstoreがうまく更新されないようであれば、動作確認する上で、いくつかexampleから省いた実装もあるので、そこが原因かと思ってます

wintyowintyo

すみません、返信が遅くなりました。
もしかしたらライブラリのバージョンの関係もあるかもしれませんが、ログイン周りの実装をした際に、dispatchしてからすぐrouter.pushでログイン後の画面に遷移したらstoreにログイン情報が入っていなかった感じでした。
リロードしたらデータは入っていましたが、そもそもpersistしないケースだと画面遷移するたびにstoreが初期値になってしまい、Functional Component内でstoreを作るやり方が間違っているんじゃないかなと思った次第です。