React Reduxのチュートリアル(Todoアプリ)をRedux Toolkitで書き換えてみた

JavaScript

Redux ToolkitとはReduxの記述を簡潔にするためのツールです。

Redux Toolkitを利用することで、以下のようなReduxの問題点が解決できます。1

Redux Toolkitによって解決できるReduxの問題点
  • 複雑なStoreの設定方法
  • 冗長なコード
  • 機能を拡張する際に別途行うライブラリのインストール作業

Redux Tookitで提供されているAPIは以下の通りです。2

Redux Tookitで提供されているAPI
  • configureStore()
  • createReducer()
  • createAction()
  • createSlice()
  • createAsyncThunk
  • createEntityAdapter
  • createSelector

今回はReact ReduxのBasic Tutorialで紹介されているサンプルアプリケーション(Todoアプリ)をRedux Toolkitで書き換えたので紹介します。

コードはTypeScriptで記述しています。React ReduxのチュートリアルをTypeScriptに書き換える手順は『React Reduxのチュートリアル(Todoアプリ)をTypeScriptで書き換えてみた』を参考にしてください。

React Reduxのチュートリアル(Todoアプリ)をTypeScriptで書き換えてみた

reduxjs/toolkitは1.5.0、react-reduxは7.1.16、reactは17.0.1、typescriptは3.8.3を利用しています。

Redux Toolkitのインストール方法

create-react-appコマンドでアプリケーションを作成する場合、reduxのインストールにRedux Toolkitが利用されます。
ですので、--template reduxもしくは--template redux-typescriptでRedux Toolkitがインストールされます。

### JavaScriptの場合
$ npx create-react-app my-app --template redux

### TypeScriptの場合
$ npx create-react-app my-app --template redux-typescript

既存のアプリケーションにRedux Toolkitのみをインストールする場合は以下のようにします。

### NPM
$ npm install @reduxjs/toolkit

### Yarn
$ yarn add @reduxjs/toolkit

StoreをconfigureStore()で書き換える

configureStore()を利用すると非同期処理のミドルウェアであるredux-thunkや、デバッグを行う際に利用するRedux DevTools Extensionが有効になります。

createStore()を利用する場合、Redux DevTools Extensionを有効にするには以下のように書く必要がありました。

src/store.ts

import { createStore } from "redux";
import rootReducer from "./reducers";

export default createStore(
  rootReducer,
  (window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
    (window as any).__REDUX_DEVTOOLS_EXTENSION__()
);

configureStore()を利用すると以下のように書き換えられます。3

src/store.ts

import { configureStore } from "@reduxjs/toolkit";
import rootReducer from "./reducers";

const store = configureStore({ reducer: rootReducer });

export default store;

ActionをcreateAction()で書き換える

通常のActionの実装手順は以下の通りです。

通常のActionの実装手順
  1. ActionTypeの定義をする
  2. ActionTypeを返すActionCreatorの実装をする

Actionとはtypepayloadをプロパティに持つオブジェクト、ActionCreatorとはActionを返すメソッドです。

createAction()は『引数で指定したActionTypeを返すActionCreator』を作成します。
つまり、createAction()を利用することで上記2つの実装を1つにまとめられます。

たとえば、『Todoを追加する』というAction(addTodo)は通常以下のように実装します。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";

let nextTodoId = 0;

type AddTodoAction = {
  type: ActionTypes.ADD_TODO;
  payload: {
    id: number;
    content: string;
  };
};

export const addTodo = (content: string): AddTodoAction => ({
  type: ActionTypes.ADD_TODO,
  payload: {
    id: ++nextTodoId,
    content,
  },
});

上記をcreateAction()を利用して書き直すと以下のようになります。

src/redux/actions.ts

import { createAction } from "@reduxjs/toolkit";

let nextTodoId = 0;

export const addTodo = createAction(
  ActionTypes.ADD_TODO,
  (content: string) => (
    {
      payload: {
        id: ++nextTodoId,
        content,
      }
    }
  )
)

Todoアプリをで利用するActionをcreateAction()で書き直した全体像は以下の通りです。

src/redux/actions.ts

import { ActionTypes } from "./actionTypes";
import { VisibilityFilterTypes } from "./types";
import { createAction } from "@reduxjs/toolkit";

let nextTodoId = 0;

type AddTodoAction = {
  type: ActionTypes.ADD_TODO;
  payload: {
    id: number;
    content: string;
  };
};

export const addTodo = createAction(
  ActionTypes.ADD_TODO,
  (content: string) => (
    {
      payload: {
        id: ++nextTodoId,
        content,
      }
    }
  )
)

type ToggleTodoAction = {
  type: ActionTypes.TOGGLE_TODO;
  payload: {
    id: number;
  };
};

export const toggleTodo = createAction(
  ActionTypes.TOGGLE_TODO,
  (id: number) => (
    {
      payload: {
        id,
      },
    }
  )
);

type SetFilterAction = {
  type: ActionTypes.SET_FILTER;
  payload: {
    filter: VisibilityFilterTypes;
  };
};

export const setFilter = createAction(
  ActionTypes.SET_FILTER,
  (filter: VisibilityFilterTypes) => (
    {
      payload: { filter },
    }
  )
);

export type TodoActions = AddTodoAction | ToggleTodoAction | SetFilterAction;

ReducerをcreateReducer()で書き換える

Reduxでは以下のようにswitch文を利用してReducerを実装します。

src/reducers/todos.ts

import { ActionTypes } from "../actionTypes";
import { TodoActions } from "../actions";
import { TodoState } from "../types";

const initialState: TodoState = {
  allIds: [],
  byIds: {},
};

const todos = (state = initialState, action: TodoActions) => {
  switch (action.type) {
    case ActionTypes.ADD_TODO: {
      const { id, content } = action.payload;
      return {
        ...state,
        allIds: [...state.allIds, id],
        byIds: {
          ...state.byIds,
          [id]: {
            content,
            completed: false,
          },
        },
      };
    }
    case ActionTypes.TOGGLE_TODO: {
      const { id } = action.payload;
      return {
        ...state,
        byIds: {
          ...state.byIds,
          [id]: {
            ...state.byIds[id],
            completed: !state.byIds[id].completed,
          },
        },
      };
    }
    default:
      return state;
  }
};

export default todos;

しかし、上記の記法には以下のような問題点があります。4

Reducerを実装する際の問題点
  • dafault caseを書き忘れる可能性がある
  • initial stateの設定を忘れる可能性がある
  • switch文の記述が若干冗長

createReducer()を利用することで上記の問題が解決できます。

createReducer()にはbuilder callbackmap objectの2つの記法があります。
builder callback記法のほうが推奨されているため4、今回はbuilder callbackを利用します。

builder callbackで用意されているメソッドは以下の通りです。5

builder callbackのメソッド
  • addCase: 特定のActionTypeのReducerを実装する場合に利用
  • addMatcher: 条件にマッチしたActionTypeのReducerを実装する場合に利用
  • addDefaultCase: 上記2つにマッチしなかったActionTypeのReducerを実装する場合に利用

createReducer()で書き直すと以下のようになります。
builder.addCaseでActionTypeと一致した時に実行するReducerを追加しています。builder.addCaseの第一引数はActionCreator、第二引数はReducer(ActionとStateを受け取り、新しいStateを返す関数)となります。

createReducerで利用するActionCreator(addTodoなど)は、createActionで定義されている前提です。

src/redux/reducers/todos.ts

import { addTodo, toggleTodo } from "../actions";
import { TodoState } from "../types";
import { createReducer } from "@reduxjs/toolkit";

const initialState: TodoState = {
  allIds: [],
  byIds: {},
};

const todos = createReducer(initialState, (builder) => {
  builder
    .addCase(addTodo, (state, action) => {
      const { id, content } = action.payload;
      return {
        ...state,
        allIds: [...state.allIds, id],
        byIds: {
          ...state.byIds,
          [id]: {
            content,
            completed: false,
          },
        },
      };
    })
    .addCase(toggleTodo, (state, action) => {
      const id = action.payload.id;
      return {
        ...state,
        byIds: {
          ...state.byIds,
          [id]: {
            ...state.byIds[id],
            completed: !state.byIds[id].completed,
          },
        },
      };
    });
});

export default todos;

同様に、visibilityFilterをcreateReducer()を利用して書き換えると以下のようになります。

src/redux/reducers/visibilityFilter.ts

import { VISIBILITY_FILTERS } from "../../constants";
import { VisibilityFilterTypes } from "../types";
import { setFilter } from "../actions";
import { createReducer } from "@reduxjs/toolkit";

const initialState = VISIBILITY_FILTERS.ALL as VisibilityFilterTypes;

const visibilityFilter = createReducer(initialState, (builder) => {
  builder.addCase(setFilter, (state, action) => {
    return action.payload.filter;
  });
});

export default visibilityFilter;

ActionとReducerをcreateSlice()で書き換える

createSlice()を利用することでActionType、ActionCreator、Reducerをまとめて定義できます。
createSlice()は内部でcreateAction()createReducer()を利用しています。
つまり、createAction()createReducer()を融合したものがcreateSlice()になります。createSlice()を利用する場合、createAction()createReducer()は不要となります。

createSlice()のパラメータは以下の通りです。6

createSlice()のパラメーター
  • initialState: 初期値
  • name: Sliceの名前。ActionType名の接頭辞として利用される。
  • reducers: Reducerの定義箇所

todosに関するAction・ReducerをcreateSlice()で書き直すと以下のようになります。

src/redux/todosSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import { TodoState } from "../redux/types";

let nextTodoId = 0;

const initialState: TodoState = {
  allIds: [],
  byIds: {},
};

const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    addTodo(state, action) {
      const content = action.payload;
      let id = ++nextTodoId;
      return {
        ...state,
        allIds: [...state.allIds, id],
        byIds: {
          ...state.byIds,
          [id]: {
            content,
            completed: false,
          },
        },
      };
    },
    toggleTodo(state, action) {
      const id = action.payload;
      return {
        ...state,
        byIds: {
          ...state.byIds,
          [id]: {
            ...state.byIds[id],
            completed: !state.byIds[id].completed,
          },
        },
      };
    },
  },
});

export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;

createSlice()を利用した場合のReducerの結合は以下のようになります。
createSlice()export defaultしているReducerを利用しています。

redux/reducers/index.ts

import { combineReducers } from "redux";
import visibilityFilter from "./visibilityFilter";
- import todos from "./todos";
+ import todosReducer from "../todosSlice";

export default combineReducers({
- todos,
+ todos: todosReducer,
  visibilityFilter
});

createSlice()exportしたActionをコンポーネントで利用するには以下のようにします。
ActionCreatorWithPayloadの第一引数にはPayloadの型、第二引数にはActionTypeの型(string)を指定します。

src/components/AddTodo.tsx

- import { addTodo } from "../redux/actions";
+ import { addTodo } from "../redux/todosSlice";
+ import { ActionCreatorWithPayload } from "@reduxjs/toolkit";

type AddTodoProps = {
-  addTodo: (input: string) => TodoActions;
+  addTodo: ActionCreatorWithPayload<string, string>;
};

createSlice()で自動生成されるActionTypeは[createSliceのname]/[ActionType名]となります。
たとえば、todosというnamecreateSlice()に作成したaddTodoのActionTypeはtodos/addTodoとなります。

同様にvisibilityFilterに関するcreateSlice()は以下のようになります。

src/redux/visibilityFilterSlice.ts

import { createSlice } from "@reduxjs/toolkit";
import { VISIBILITY_FILTERS } from "../constants";
import { VisibilityFilterTypes } from "../redux/types";

const initialState: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL;

const visibilityFilterSlice = createSlice({
  name: "visibilityFilter",
  initialState,
  reducers: {
    setFilter(state, action) {
      return action.payload;
    },
  },
});

export const { setFilter } = visibilityFilterSlice.actions;
export default visibilityFilterSlice.reducer;

visibilityFilterSliceも考慮した最終的なReducerの結合は以下のようになります。

redux/reducers/index.ts

import { combineReducers } from "redux";
import todosReducer from "../todosSlice";
import visibilityFilterReducer from "../visibilityFilterSlice";

export default combineReducers({
  todos: todosReducer,
  visibilityFilter: visibilityFilterReducer,
});

visibilityFilterSliceで定義されたsetFilterをコンポーネントで利用する場合は以下のようになります。

src/components/VisibilityFilters.tsx

- import { setFilter } from "../redux/actions";
+ import { setFilter } from "../redux/visibilityFilterSlice";
+ import { ActionCreatorWithPayload } from "@reduxjs/toolkit";

type VisibilityFiltersProps = {
  activeFilter: VisibilityFilterTypes;
- setFilter: (filter: VisibilityFilterTypes) => TodoActions;
+ setFilter: ActionCreatorWithPayload<VisibilityFilterTypes, string>;
};

最終的なアプリケーション

CreateAction()とCreateReducer()を利用した実装

CreateSlice()を利用した実装

まとめ

今回のまとめ
  • Redux ToolkitはReduxの記述を簡潔にするためのツール
  • configureStore()でStoreを定義するとデバッグツールと非同期ライブラリが有効になる
  • createAction()でActionの実装ができる
  • createReducer()でReducerの実装ができる
  • createSlice()でActionとReducerの実装ができる

さいごに

リファクタリングを行った最終的なコードはこちらのリポジトリで公開しております。
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!