LoginSignup
115
85

More than 3 years have passed since last update.

Next.js + TypeScriptのプロジェクトにReduxを導入する

Posted at

この記事の概要

Next.js + TypeScriptのプロジェクトにReduxを導入する手順を解説した記事です。

対象読者

  • Next.jsを触った事がある人
  • TypeScriptの基礎的な知識がある人
  • Reduxの基礎的な知識はある人

実行環境

  • Node.js 12.16.2
  • Next.js 9.3.4
  • React 16.13.1
  • Redux 4.0.5

導入した際のGitHub PR

こちら になります。

この差分を見て内容が分かる人はこれ以降を読む必要はありません。

これ以降の章はこの手順に関して1つ1つ解説していきます。

具体的な手順とゴール

定番ですがReduxを使ってカウンターアプリを作ってみます。

また非同期でカウントアップを行う機能と非同期カウントアップが失敗した際の処理についても実装します。

本来カウンターアプリなどのシンプルな機能ではReduxは必要ないと思います。

しかしロジックがシンプルなのでReduxの動きは理解しやすと思います。

必要なpackageをインストールします

今回インストールするpackageは下記の通りになります。

redux
react-redux
redux-logger
@reduxjs/toolkit
@types/react-redux
@types/redux-logger
// npmを使う場合
npm install redux react-redux @reduxjs/toolkit redux-logger --save
npm install @types/react-redux @types/redux-logger --save-dev

// yarnを使う場合
yarn add redux react-redux @reduxjs/toolkit redux-logger
yarn add @types/react-redux @types/redux-logger --dev

この中で @reduxjs/toolkit というpackageは比較的新しい物です。

かつてReduxは最小構成でもコード量が多くなりがちで、初学者には敬遠されがちでした。

しかしこの @reduxjs/toolkit を利用する事で少ないコード量での実装が可能となります。

詳しくは 公式 Redux Toolkit をご覧下さい。

ディレクトリ構成について

Reduxのディレクトリ構成は色々なパターンがあります。

プロジェクトによって最適解は異なりますが今回は Re-Ducksパターン を採用する事にしました。

sliceの作成

createSlice という関数を使います。

これを使うとReduxのreducerとactionを同時に定義出来て、さらにTypeScriptの恩恵も受けられます。

特別な理由がなければ createSlice を使うで問題ないと個人的には思います。

以下の内容で counterSlice を定義します。

src/ducks/counter/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export type CounterState = {
  count: number;
  loading: boolean;
  error: boolean;
  errorMessage: string;
};

export const initialState: CounterState = {
  count: 0,
  loading: false,
  error: false,
  errorMessage: '',
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    incrementCounter: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count + action.payload,
    }),
    decrementCounter: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count - action.payload,
    }),
  },
});

export default counterSlice;

Storeの作成

以下の内容で作成します。

src/ducks/createStore.ts
import { Store, combineReducers } from 'redux';
import logger from 'redux-logger';
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import counterSlice, { initialState as counterState } from './counter/slice';

const rootReducer = combineReducers({
  counter: counterSlice.reducer,
});

const preloadedState = () => {
  return { counter: counterState };
};

export type StoreState = ReturnType<typeof preloadedState>;

export type ReduxStore = Store<StoreState>;

const createStore = () => {
  const middlewareList = [...getDefaultMiddleware(), logger];

  return configureStore({
    reducer: rootReducer,
    middleware: middlewareList,
    devTools: process.env.NODE_ENV !== 'production',
    preloadedState: preloadedState(),
  });
};

export default createStore;

Custom Appの作成

以下の内容で作成します。

先程作成した createStore を読み込む形です。

src/pages/_app.tsx
import React from 'react';
import { AppProps } from 'next/app';
import { Provider } from 'react-redux';
import createStore from '../ducks/createStore';

const MyApp = ({ Component, pageProps }: AppProps) => {
  return (
    <Provider store={createStore()}>
      <Component {...pageProps} />
    </Provider>
  );
};

export default MyApp;

MyApp という名前は分かりやすい名前であれば何でも構いません。

Custom Appに関する詳しい内容は 公式ドキュメント をご覧下さい。

pageComponentの作成

以下のようにComponentを作成します。

src/pages/counter.tsx
import React from 'react';
import { useDispatch } from 'react-redux';
import counterSlice from '../ducks/counter/slice';
import { useCounterState } from '../ducks/counter/selectors';

const CounterPage: React.FC = () => {
  const dispatch = useDispatch();
  const state = useCounterState().counter;

  const onClickIncrement = () => {
    dispatch(counterSlice.actions.incrementCounter(1));
  };

  const onClickDecrement = () => {
    dispatch(counterSlice.actions.decrementCounter(1));
  };

  return (
    <>
      <button type="button" onClick={onClickIncrement}>
        ふやす
      </button>
      <button type="button" onClick={onClickDecrement}>
        へらす
      </button>
      <p>ねこが{state.count} 匹いる</p>
    </>
  );
};

export default CounterPage;

ちなみに useCounterState という関数ですが以下のような実装になっています。

src/ducks/counter/selectors.ts
import { useSelector } from 'react-redux';
import { CounterState } from './slice';

export const useCounterState = () => {
  return useSelector((state: { counter: CounterState }) => state);
};

counter.png

非同期Actionの実装

先程実装したcounterは同期Actionです。

実戦ではAPIへの通信などで非同期なActionを実装する事が多いです。

その場合も @reduxjs/toolkit の仕組みに乗っかればOKです。

まずは非同期Action用の関数を実装します。

サンプルなのでとりあえず以下のように1秒間だけスリープする処理を入れておきます。

src/ducks/counter/asyncActions.ts
import { createAsyncThunk } from '@reduxjs/toolkit';

const sleep = (microSecond: number) =>
  new Promise((resolve) => setTimeout(resolve, microSecond));

export const asyncIncrementCounter = createAsyncThunk<number, number>(
  'counter/asyncIncrementCounter',
  async (arg: number): Promise<number> => {
    await sleep(1000);

    // エラーが起きた際の動きを確認する為、一定確率でエラーが起きるようにしてある
    const randNum = Math.floor(Math.random() * Math.floor(10));
    if (randNum === 0 || randNum === 5 || randNum === 1) {
      return Promise.reject(new Error('asyncIncrementCounter error'));
    }

    return arg;
  },
);

次に src/ducks/counter/slice.ts を変更します。

createSlice の引数に extraReducers を渡すように変更します。

src/ducks/counter/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { asyncIncrementCounter } from './asyncActions';

export type CounterState = {
  count: number;
  loading: boolean;
  error: boolean;
  errorMessage: string;
};

export const initialState: CounterState = {
  count: 0,
  loading: false,
  error: false,
  errorMessage: '',
};

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    incrementCounter: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count + action.payload,
    }),
    decrementCounter: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count - action.payload,
    }),
  },
  extraReducers: (builder) => {
    builder.addCase(asyncIncrementCounter.pending, (state) => {
      return {
        ...state,
        loading: true,
        error: false,
        errorMessage: '',
      };
    });
    builder.addCase(
      asyncIncrementCounter.rejected,
      (state, action: RejectedAction<number>) => {
        return {
          ...state,
          loading: false,
          error: true,
          errorMessage: action.error.message,
        };
      },
    );
    builder.addCase(
      asyncIncrementCounter.fulfilled,
      (state, action: PayloadAction<number>) => {
        return {
          ...state,
          count: state.count + action.payload,
          loading: false,
          error: false,
          errorMessage: '',
        };
      },
    );
  },
});

export default counterSlice;

以下のように3パターンの関数を実装する必要があります。

  • asyncIncrementCounter.pending (非同期Action実行中に実行される)
  • asyncIncrementCounter.rejected (非同期Actionに失敗した際に実行される)
  • asyncIncrementCounter.fulfilled (非同期Actionに正常した際に実行される)

ちなみに action の型定義は以下のようになっています。

公式の型定義になかったので自前で定義しています。(本当はこういう自前での型定義ではなく出来る限り公式の型定義を使いたいところです)

src/ducks/ReduxAction.d.ts
type PendingAction<ThunkArg> = {
  type: string;
  meta: {
    requestId: string;
    arg: ThunkArg;
  };
}

type FulfilledAction<ThunkArg, T> = {
  type: string;
  payload: T;
  meta: {
    requestId: string;
    arg: ThunkArg;
  };
}

type RejectedAction<ThunkArg> = {
  type: string;
  /* eslint @typescript-eslint/no-explicit-any: 0 */
  error:
    | {
        name?: string;
        message?: string;
        code?: string;
        stack?: string;
      }
    | any;
  meta: {
    requestId: string;
    arg: ThunkArg;
    aborted: boolean;
  };
}

次にpageComponentも修正します。

src/pages/counter.tsx
import React from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import counterSlice from '../ducks/counter/slice';
import { useCounterState } from '../ducks/counter/selectors';
import { asyncIncrementCounter } from '../ducks/counter/asyncActions';

const StyledMessage = styled.p`
  color: red;
  font-weight: bold;
`;

const CounterPage: React.FC = () => {
  const dispatch = useDispatch();
  const state = useCounterState().counter;

  const onClickIncrement = () => {
    dispatch(counterSlice.actions.incrementCounter(1));
  };

  const onClickDecrement = () => {
    dispatch(counterSlice.actions.decrementCounter(1));
  };

  const onClickAsyncIncrement = async () => {
    await dispatch(asyncIncrementCounter(10));
  };

  return (
    <>
      <button type="button" onClick={onClickIncrement}>
        ふやす
      </button>
      <button type="button" onClick={onClickDecrement}>
        へらす
      </button>
      <button
        type="button"
        onClick={onClickAsyncIncrement}
        disabled={state.loading}
      >
        非同期でふやす
      </button>
      <p>ねこが{state.count} 匹いる</p>
      {state.loading ? <p>通信中</p> : ''}
      {state.error ? (
        <StyledMessage>問題が発生しました。{state.errorMessage}</StyledMessage>
      ) : (
        ''
      )}
    </>
  );
};

export default CounterPage;

「非同期でふやす」ボタンと通信中メッセージ、「非同期でふやす」ボタンをクリックした際に先程実装した非同期Actionを呼び出す処理を実装してあります。

ブラウザで確認すると以下のような動きになります。

正常に非同期Actionが終了(1秒間は通信中のメッセージが表示される)

AsyncCounter.png

非同期Actionでエラーが発生した場合

AsyncCounterError.png

redux-saga との比較

ここでは詳しい説明はしませんがReduxの非同期処理と言えば redux-saga がデファクトスタンダードだと思います。

しかし今回は @reduxjs/toolkitcreateAsyncThunk を利用しました。

redux-saga は確かに非同期処理とReduxActionを完全に分離出来ているので設計上は良いと思うのですが、少々学習コストが高いので最初は createAsyncThunk で様子を見るで良いと個人的には思います。

おわりに

@reduxjs/toolkit を使ったReduxの導入手順になります。

少し前にReduxを導入した頃に比べてかなり楽になったと思います。

またReduxの公式が出しているpackageなのでそこも安心感がありますね。

以上になります。最後まで読んで頂きありがとうございました。

参考にした記事

115
85
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
115
85