🍰

createSlice で楽に action と reducer を管理しよう

2021/05/17に公開

始め

redux-toolkit を触ってみて、createSliceで action も reducer も一緒に管理できるって素敵ー!と思いました。

1. Action

1-1. Reduxの場合

既存の Reduxでは action type 定義と action creator 作成を別々で宣言してました。

//action type 定義
const INCREMENT = 'counter/increment'

//action creator 作成
function increment(amount: number) {
  return {
    type: INCREMENT,
    payload: amount,
  }
}

const action = increment(3)
// { type: 'counter/increment', payload: 3 }

1-2. createAction

redux-toolkit ではcreateActionを使って action を生成します。

  • createActionaction type を引数で受け取って、その type の action creator を返す関数
const increment = createAction('counter/increment')

let action = increment()
// 引数(payload)がない場合
// 返り値は { type: 'counter/increment' }

action = increment(3)
// 引数(payload)がある場合
// 返り値は { type: 'counter/increment', payload: 3 }

既存の Redux では別々でやってたことを一回でできるようになりました。

もしpayloadを整形したいときやロジックを追加したいときはcreateActionの第2引数にコールバック関数を渡します。

import { createAction, nanoid } from '@reduxjs/toolkit'

const addTodo = createAction('todos/add', function prepare(text: string) {
  return {
    payload: {
      text,
      id: nanoid(),
      createdAt: new Date().toISOString(),
    },
  }
})

console.log(addTodo('Write more docs'))
/**
 * {
 *   type: 'todos/add',
 *   payload: {
 *     text: 'Write more docs',
 *     id: '4AJvwMSWEHCchcWYga3dj',
 *     createdAt: '2019-10-03T07:53:36.581Z'
 *   }
 * }
 **/

action creator が受け取った引数'Write more docs'がそのまま第2引数であるprepare関数の引数になってますね。これを利用してpayloadの形に手を加えることができます。

この場合 return されるオブジェクトは Redux-toolkit が決めた action オブジェクトのフォーマット Flux Standard Action に合わせなければなりません。

2. Reducer

2-1. Reduxの場合

既存の Redux では reducer を実装するとき switch 文などで action type ごとにろロジックを書いてました。よく default case を書き忘れたり initial state の設定を忘れたりしますね。

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, value: state.value + 1 }
    case 'decrement':
      return { ...state, value: state.value - 1 }
    default:
      return state
  }
}

2-2. createReducer

redux-toolkit の createReducerでは以下のように reducer 関数を作成します。

import { createAction, createReducer } from '@reduxjs/toolkit'

interface CounterState {
  value: number
}

const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')

const initialState = { value: 0 } as CounterState

const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      state.value++
    })
    .addCase(decrement, (state, action) => {
      state.value--
    })
})

1番目立つ部分は switch 文がなくなったこと!これで switch 文地獄から脱出しました。

createReducer関数は2つの引数を受け取ります。

  • 第1引数:初期値
  • 第2引数:builder というオブジェクトを引数で受け取るコールバック関数

switch 文の代わりに builder オブジェクトで action ごとの reducer を生成する仕組みです。builder オブジェクトには3つのメソッドがありますので、一つずつ見ていきましょう。

builder.addCase

特定 action type の reducer を実装するとき使います。switch 文のcaseに該当するイメージでしょう。

  • 第1引数:action type 文字列もしくはcreateActionで生成された action creator
  • 第2引数:第1引数の action に対する reducer 関数

builder.addMatcher

ある条件にマッチする action type の reducer を実装するとき使います。

  • 第1引数:matcher 関数
  • 第2引数:第1引数の action に対する reducer 関数
const initialState = {}
const reducer = createReducer(initialState, (builder) => {
  builder
    // .addCase(...)
    .addMatcher(
      (action) => action.type.endsWith('/rejected'),
      (state, action) => {
        state[action.meta.requestId] = 'rejected'
      }
    )
})

この例だと/rejectedで終わる action type に対する reducer を実装してます。

builder.addDefaultCase

上記の2つにマッチしなかった action type の reducer を実装するとき使います。switch 文のdefault case と一緒かと思います。

  • 第1引数:default case に対する reducer 関数
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
  builder
    // .addCase(...)
    // .addMatcher(...)
    .addDefaultCase((state, action) => {
      state.otherActions++
    })
})

ちなみにこのメソッドたちは必ずaddCaseaddMatcheraddDefaultCaseの順に書かなければならないようです。

+)createReducerの使い方には builder オブジェクトを用いる方法以外に Map オブジェクトを使う方法もあります。

const increment = createAction('increment')
const decrement = createAction('decrement')

const counterReducer = createReducer(0, {
  [increment]: (state, action) => state + action.payload,
  [decrement.type]: (state, action) => state - action.payload
})

ですが、Redux Toolkit 公式ドキュメントでは Typescript と IDEs との相性を理由で builder オブジェクトを使う方法をおすすめしています。

The recommended way of using createReducer is the builder callback notation, as it works best with TypeScript and most IDEs.

3.createSlice

3-1. Aciotn + Reducer → Slice

ついに slice まで来ましたー!!

slice というのは簡単に言うと reducer 関数と action creator を含むオブジェクトです。

上で見たcreateActioncreateReducerを使わなくても、createSliceを使ったら reducer を作成するだけで自動的に action type も定義してくれるし action creator も生成してくれます。 action と reducer を一発で解決できるということです。

createSlice関数は以下のように1つのオブジェクトを引数として受け取ります。

function createSlice({
    //名称(action type に使われる)
    name: string,
    //reducer の初期値
    initialState: any,
    //case reducer たちのオブジェクト
    reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
    //別で作成しておいた action に対する reducer(任意)
    extraReducers?:
    | Object<string, ReducerFunction>
    | ((builder: ActionReducerMapBuilder<State>) => void)
})

簡単なサンプルコードも見てみましょう。

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Item {
  id: string
  text: string
}

const todoSlice = createSlice({
  name: 'todos',
  initialState: [] as Item[],
  reducers: {
    addTodo: (state, action: PayloadAction<Item>) => {
      state.push(action.payload)
    },
  },
})

export const { addTodo } = todoSlice.actions
export default todoSlice.reducer

reducersオブジェクトで reducer 関数を作成したら、その key から自動的に action type が生成されます。nameは action type の prefix として使われます。この例だと prefix はtodosで、action type はaddTodoになりますね。

createActionの第2引数に渡してたprepare関数もここで使えます。reducersのオブジェクト部分を以下のように書いたら、payloadをカスタムすることができます。

reducers: {
    addTodo: {
      reducer: (state, action: PayloadAction<Item>) => {
        state.push(action.payload)
      },
      prepare: (text: string) => {
        const id = nanoid()
        return { payload: { id, text } }
      },
    },
  }

またcreateAsyncThunkで生成した非同期 action など、別で作成しておいた action に対する reducer を作成するときは extraReducersを使います。

const todoSlice = createSlice({
  name: 'todos',
  initialState: [] as Item[],
  reducers: {},
  extraReducers: (builder) => {
    builder
    .addCase(fetchUserById.pending, (state, action) => {
      //非同期処理中のロジック
    })
    .addCase(fetchUserById.fulfilled, (state, action) => {
      //非同期処理成功時のロジック
    })
    .addCase(fetchUserById.rejected, (state, action) => {
      //非同期処理失敗時のロジック
    })
  }
})

外部の action に対する reducer 関数を作成しているので、extraReducersからは action が自動的に生成されません。

extraReducersの関数にはcreateReducerの第2引数と同様にbuilder.addCase以外にもbuilder.addMatcherbuilder.addDefaultも使えます。

🙋🏻‍♀️ immer の produce API 利用
 今までのサンプルコートで1つおかしな部分がありました。state を変更するときpushを使うなど、immutable を守ってないということです。それでも大丈夫な理由は、createReducercreateSlice内部的に immer の produce API を使っているからです。そのおかげで state を直接変更しても勝手に immutable を守るオブジェクトを生成して返してくれます。とても便利になりましたね。
 →immutable と immer について詳しく知りたいかたは「イミュータブルが大事な理由、そしてImmerで簡単実現!」をご参考ください。

3-2. 返り値

引数を受け取ったcreateSliceはこのようなオブジェクトを返します。

{
    name : string,
    reducer : ReducerFunction,
    actions : Record<string, ActionCreator>,
    caseReducers: Record<string, CaseReducer>
}

reducerにはreducersextraReducersに作成した reducer 関数たちが、actionsにはreducersから生成された action たちがまとめられてます。

そして3-1のサンプルコートのように、slice.actionsを export して必要なところで使う、slice.reducer を export して store のcombineReducersでまとめる、という流れです。

+) ちなみに "slice" という名前になったのは「combineReducerでまとめられた root reducer を構成する1つのスライス(薄片)だから」という理由らしいです。

終わり

思ったより時間かかったー!action と reducer という単語見すぎてゲシュタルト崩壊起こしそうです笑

余裕があったら selector についても書きたいです!

GitHubで編集を提案

Discussion