createSlice で楽に action と reducer を管理しよう
始め
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 を生成します。
-
createAction
:action 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++
})
})
ちなみにこのメソッドたちは必ずaddCase
→addMatcher
→addDefaultCase
の順に書かなければならないようです。
+)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 を含むオブジェクトです。
上で見たcreateAction
とcreateReducer
を使わなくても、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.addMatcher
、builder.addDefault
も使えます。
🙋🏻♀️ immer の produce API 利用
今までのサンプルコートで1つおかしな部分がありました。state を変更するときpush
を使うなど、immutable を守ってないということです。それでも大丈夫な理由は、createReducer
とcreateSlice
が内部的に immer の produce API を使っているからです。そのおかげで state を直接変更しても勝手に immutable を守るオブジェクトを生成して返してくれます。とても便利になりましたね。
→immutable と immer について詳しく知りたいかたは「イミュータブルが大事な理由、そしてImmerで簡単実現!」をご参考ください。
3-2. 返り値
引数を受け取ったcreateSlice
はこのようなオブジェクトを返します。
{
name : string,
reducer : ReducerFunction,
actions : Record<string, ActionCreator>,
caseReducers: Record<string, CaseReducer>
}
reducer
にはreducers
とextraReducers
に作成した reducer 関数たちが、actions
にはreducers
から生成された action たちがまとめられてます。
そして3-1のサンプルコートのように、slice.actions
を export して必要なところで使う、slice.reducer
を export して store のcombineReducers
でまとめる、という流れです。
+) ちなみに "slice" という名前になったのは「combineReducer
でまとめられた root reducer を構成する1つのスライス(薄片)だから」という理由らしいです。
終わり
思ったより時間かかったー!action と reducer という単語見すぎてゲシュタルト崩壊起こしそうです笑
余裕があったら selector についても書きたいです!
Discussion