この記事の概要
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
を定義します。
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の作成
以下の内容で作成します。
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
を読み込む形です。
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を作成します。
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
という関数ですが以下のような実装になっています。
import { useSelector } from 'react-redux';
import { CounterState } from './slice';
export const useCounterState = () => {
return useSelector((state: { counter: CounterState }) => state);
};
非同期Actionの実装
先程実装したcounterは同期Actionです。
実戦ではAPIへの通信などで非同期なActionを実装する事が多いです。
その場合も @reduxjs/toolkit
の仕組みに乗っかればOKです。
まずは非同期Action用の関数を実装します。
サンプルなのでとりあえず以下のように1秒間だけスリープする処理を入れておきます。
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
を渡すように変更します。
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
の型定義は以下のようになっています。
公式の型定義になかったので自前で定義しています。(本当はこういう自前での型定義ではなく出来る限り公式の型定義を使いたいところです)
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も修正します。
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秒間は通信中のメッセージが表示される)
非同期Actionでエラーが発生した場合
redux-saga
との比較
ここでは詳しい説明はしませんがReduxの非同期処理と言えば redux-saga がデファクトスタンダードだと思います。
しかし今回は @reduxjs/toolkit
の createAsyncThunk
を利用しました。
redux-saga
は確かに非同期処理とReduxActionを完全に分離出来ているので設計上は良いと思うのですが、少々学習コストが高いので最初は createAsyncThunk
で様子を見るで良いと個人的には思います。
おわりに
@reduxjs/toolkit を使ったReduxの導入手順になります。
少し前にReduxを導入した頃に比べてかなり楽になったと思います。
またReduxの公式が出しているpackageなのでそこも安心感がありますね。
以上になります。最後まで読んで頂きありがとうございました。