Make it to make it

いろいろ作ってアウトプットするブログ

Redux Toolkitを用いたリファクタ

React, Redux, TypeScriptの組み合わせだと、どうしてもファイル数が増えて、記述があちらこちらに散らばることが多々あり、リーダビリティが下がってしまう。

Redux Toolkit (RTK) を使えば、あちらこちらに記述していた書き方を集約して書ける以外にも、redux周りの面倒なパッケージのインストールやミドルウェアの部分も包含されているので、手間が省ける。

youtu.be

こちらの動画にわかりやすい比較サンプルが載っていたので、少々こちらでいじった上で、従来の書き方とRedux Toolkitを用いた書き方を比べてみる。

下記では、理解のしやすさ重視で、TODOリストのReduxを、従来の書き方とRTKでの書き方で1ファイルにまとめて見比べている。

コード

redux-original.ts

import { combineReducers, createStore, applyMiddleware } from 'redux';
import { v1 as uuid } from 'uuid';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';

export interface Todo {
  id: string;
  desc: string;
  isComplete: boolean;
}

export interface State {
  todos: Todo[];
  selectedTodo: string | null;
  counter: number;
}

/**
 * Constants
 */
const CREATE_TODO = 'CREATE_TODO';
const EDIT_TODO = 'EDIT_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';
const SELECT_TODO = 'SELECT_TODO';

/**
 * Actions & Action types
 */
interface CreateTodoActionType {
  type: typeof CREATE_TODO;
  payload: Todo;
}
export const createTodoActionCreator = ({
  desc,
}: {
  desc: string;
}): CreateTodoActionType => ({
  type: CREATE_TODO,
  payload: {
    id: uuid(),
    desc,
    isComplete: false,
  },
});

interface EditTodoActionType {
  type: typeof EDIT_TODO;
  payload: { id: string; desc: string };
}
export const editTodoActionCreator = ({
  id,
  desc,
}: {
  id: string;
  desc: string;
}): EditTodoActionType => ({
  type: EDIT_TODO,
  payload: { id, desc },
});

interface ToggleTodoActionType {
  type: typeof TOGGLE_TODO;
  payload: { id: string; isComplete: boolean };
}
export const toggleTodoActionCreator = ({
  id,
  isComplete,
}: {
  id: string;
  isComplete: boolean;
}): ToggleTodoActionType => ({
  type: TOGGLE_TODO,
  payload: { id, isComplete },
});

interface DeleteTodoActionType {
  type: typeof DELETE_TODO;
  payload: { id: string };
}
export const deleteTodoActionCreator = ({
  id,
}: {
  id: string;
}): DeleteTodoActionType => ({
  type: DELETE_TODO,
  payload: { id },
});

interface SelectTodoActionType {
  type: typeof SELECT_TODO;
  payload: { id: string };
}
export const selectTodoActionCreator = ({
  id,
}: {
  id: string;
}): SelectTodoActionType => ({
  type: SELECT_TODO,
  payload: { id },
});

/**
 * Reducers
 */
const todosInitialState: Todo[] = [
  {
    id: uuid(),
    desc: 'Learn React',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux-ToolKit',
    isComplete: false,
  },
];

type TodoActionTypes =
  | CreateTodoActionType
  | EditTodoActionType
  | ToggleTodoActionType
  | DeleteTodoActionType;
const todosReducer = (
  state: Todo[] = todosInitialState,
  action: TodoActionTypes
) => {
  switch (action.type) {
    case CREATE_TODO: {
      const { payload } = action;
      return [...state, payload];
    }
    case EDIT_TODO: {
      const { payload } = action;
      return state.map((todo) =>
        todo.id === payload.id ? { ...todo, desc: payload.desc } : todo
      );
    }
    case TOGGLE_TODO: {
      const { payload } = action;
      return state.map((todo) =>
        todo.id === payload.id
          ? { ...todo, isComplete: payload.isComplete }
          : todo
      );
    }
    case DELETE_TODO: {
      const { payload } = action;
      return state.filter((todo) => todo.id !== payload.id);
    }
    default: {
      return state;
    }
  }
};

type SelectedTodoActionTypes = SelectTodoActionType;
const selectedTodoReducer = (
  state: string | null = null,
  action: SelectedTodoActionTypes
) => {
  switch (action.type) {
    case SELECT_TODO: {
      const { payload } = action;
      return payload.id;
    }
    default: {
      return state;
    }
  }
};

const counterReducer = (state: number = 0, action: TodoActionTypes) => {
  switch (action.type) {
    case CREATE_TODO: {
      return state + 1;
    }
    case EDIT_TODO: {
      return state + 1;
    }
    case TOGGLE_TODO: {
      return state + 1;
    }
    case DELETE_TODO: {
      return state + 1;
    }
    default: {
      return state;
    }
  }
};

const reducers = combineReducers({
  todos: todosReducer,
  selectedTodo: selectedTodoReducer,
  counter: counterReducer,
});

/**
 * Store
 */
export default createStore(
  reducers,
  composeWithDevTools(applyMiddleware(thunk, logger))
);

redux-rtk.ts

import {
  configureStore,
  createSlice,
  getDefaultMiddleware,
  PayloadAction,
} from '@reduxjs/toolkit';
import { v1 as uuid } from 'uuid';
import logger from 'redux-logger';

export interface Todo {
  id: string;
  desc: string;
  isComplete: boolean;
}

export interface State {
  todos: Todo[];
  selectedTodo: string | null;
  counter: number;
}

const todosInitialState: Todo[] = [
  {
    id: uuid(),
    desc: 'Learn React',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux-ToolKit',
    isComplete: false,
  },
];

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosInitialState,
  reducers: {
    create: {
      reducer: (
        state,
        {
          payload,
        }: PayloadAction<{ id: string; desc: string; isComplete: boolean }>
      ) => {
        state.push(payload);
      },
      prepare: ({ desc }: { desc: string }) => ({
        payload: {
          id: uuid(),
          desc,
          isComplete: false,
        },
      }),
    },
    edit: (state, { payload }: PayloadAction<{ id: string; desc: string }>) => {
      const matchedTodo = state.find((todo) => todo.id === payload.id);
      if (typeof matchedTodo !== 'undefined') {
        matchedTodo.desc = payload.desc;
      }
    },
    toggle: (
      state,
      { payload }: PayloadAction<{ id: string; isComplete: boolean }>
    ) => {
      const matchedTodo = state.find((todo) => todo.id === payload.id);
      if (typeof matchedTodo !== 'undefined') {
        matchedTodo.isComplete = payload.isComplete;
      }
    },
    remove: (state, { payload }: PayloadAction<{ id: string }>) => {
      const index = state.findIndex((todo) => todo.id === payload.id);
      if (index !== -1) {
        state.splice(index, 1);
      }
    },
  },
});

const selectedTodoSlice = createSlice({
  name: 'selectedTodo',
  initialState: '',
  reducers: {
    select: (state, { payload }: PayloadAction<{ id: string }>) => payload.id,
  },
});

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {},
  extraReducers: {
    [todosSlice.actions.create.type]: (state) => state + 1,
    [todosSlice.actions.edit.type]: (state) => state + 1,
    [todosSlice.actions.toggle.type]: (state) => state + 1,
    [todosSlice.actions.remove.type]: (state) => state + 1,
  },
});

export const {
  create: createTodoActionCreator,
  edit: editTodoActionCreator,
  toggle: toggleTodoActionCreator,
  remove: deleteTodoActionCreator,
} = todosSlice.actions;
export const { select: selectTodoActionCreator } = selectedTodoSlice.actions;

const reducer = {
  todos: todosSlice.reducer,
  selectedTodo: selectedTodoSlice.reducer,
  counter: counterSlice.reducer,
};

const middleware = [...getDefaultMiddleware(), logger];

export default configureStore({
  reducer,
  middleware,
});

RTKメモ

  • RTKをreactプロジェクトに導入する場合に必要な最低のパッケージは、@reduxjs/toolkitreact-reduxだけでこと足りる。
    • reduxミドルウェアも内包しているので
      (RTKのpackage.jsonを見ればわかる)
  • RTKでの非同期処理はredux-thunkを用いて行われている
  • RTKではimmerを内包しているので、mutableな形でstateの更新ができる
  • RTKでreducer, acitionsなどを記述する際は、大体createSlice一本でまとまる
  • RTKではinitialStateのフィールドが必須になっているので、従来のような定義漏れが起こりにくい
  • RTKでstateがプリミティブ型の場合は、mutateする形ではなく、従来のreduxのかたちでimmutableに書く
  • RTKでreducerに渡す際に、initialStateが上記の例のようにimpureな副作用をともなうファンクションなどを含んでいる場合、直下のreducerのメソッド内に、さらにreducerprepareメソッドを書くことで、扱うことができるようになる