目次
前提知識: データ更新とイミュータビリティ
Immerを理解するために必要な前提知識について紹介します。
詳細は【React】JavaScriptのイミュータビリティを理解して正しくState更新するで紹介しています。
データの更新方法について
データの更新方法には『データの参照先の値を上書きする方法』と『データの参照先を変更して値を置き換える方法』の2つがあります。
書き換えを意味するmutate(ミューテート)という英単語を用いて、前者は『ミューテートを伴うデータ更新』、後者は『ミューテートを伴わないデータ更新』と呼ばれています。1
ミューテートを伴うデータ更新の例は以下の通りです。
type Player = {
score: number,
name: string,
};
const player: Player = { score: 1, name: 'Jeff' };
// ミューテートを伴う更新(参照先の値を上書きする)
const changePlayer = (player: Player) => {
player.score = 2
return player;
}
// ミューテートを伴う更新のため、newPlayerとplayerの参照先は同じ
const newPlayer = changePlayer(player)
console.log(newPlayer)
// { score: 2, name: 'Jeff' }
// 参照先の値が上書きされたため、newPlayerの値も更新される
console.log(player)
// { score: 2, name: 'Jeff' }
ミューテートを伴わないデータ更新の例は以下の通りです。
type Player = {
score: number,
name: string,
};
const player: Player = { score: 1, name: 'Jeff' };
// ミューテートを伴わない更新(参照先を変更して値を更新)
const changePlayer = (player: Player) => Object.assign({}, player, { score: 2 })
// ミューテートを伴わない更新のため、newPlayerとplayerの参照先は別
const newPlayer = changePlayer(player)
console.log(newPlayer)
// { score: 2, name: 'Jeff' }
// 参照先は別のためplayerの値は変わらない
console.log(player)
// { score: 1, name: 'Jeff' }
イミュータブルとミュータブルについて
イミュータブル(immutable)なデータとは『一度作成したら値を変更できない』性質、ミュータブル(mutable)なデータとは『一度作成した後も値が変更できる』性質を持つデータです。
プリミティブ型はイミュータブル、オブジェクト型はミュータブルです。
イミュータビリティについて
イミュータビリティ(immutability、不変性)とは参照先のデータが不変であることを意味します。
プリミティブ型はイミュータブルな性質を持つため、プリミティブ型のデータ更新ではイミュータビリティが保証されています。
一方、オブジェクトはミュータブルな性質を持つため、オブジェクトのデータ更新ではイミュータビリティが保証されていません。
React・ReduxのStateの更新はミューテートを伴わない方法で行う
変更を正しく検知できるようにするため、React・ReduxのStateの更新はミューテートを伴わない方法、つまり参照先の値を上書きしないイミュータビリティを保証した方法が推奨されています。
ミューテートを伴わないデータ更新の問題点
オブジェクトの値を変更する際、ミューテートを伴わない方法だと不便な点があります。
オブジェクトはミュータブルな性質を持つため直接データを変更せず、浅いコピー(参照先はコピーせず、値のみをコピーすること)で生成したオブジェクトに対してデータ更新をする必要があります。
浅いコピーはスプレッド演算子などで実現できますが、オブジェクトのネストが増えるごとにコードの複雑さが増してしまいます。
なお、浅いコピーの詳細解説はJavaScriptの浅い比較・浅い(深い)コピーの挙動まとめで紹介しています。
Immerについて
Immerはミューテートを伴わないデータ更新を簡潔に記述できるようにするパッケージです。
Immerの利用方法
Immerが提供するproduce
メソッドの定義はproduce(baseState, recipe: (draftState) => void): nextState
です。
baseState
から生成されたdraftState
の値を変更することでミューテートを伴わないデータ更新を実現します。
具体例は以下の通りです。
// https://immerjs.github.io/immer/produce
import produce from "immer"
const baseState = [
{
title: "Learn TypeScript",
done: true
},
{
title: "Try Immer",
done: false
}
]
const nextState = produce(baseState, draftState => {
draftState.push({title: "Tweet about it"})
draftState[1].done = true
})
実行結果は以下の通りです。
// https://immerjs.github.io/immer/produce
// the new item is only added to the next state,
// base state is unmodified
expect(baseState.length).toBe(2)
expect(nextState.length).toBe(3)
// same for the changed 'done' prop
expect(baseState[1].done).toBe(false)
expect(nextState[1].done).toBe(true)
// unchanged data is structurally shared
expect(nextState[0]).toBe(baseState[0])
// ...but changed data isn't.
expect(nextState[1]).not.toBe(baseState[1])
ReduxにおけるImmerを利用したリファクタリング例
例としてReact Reduxの公式ドキュメント内で紹介されているTutorialのToDoアプリケーションを利用します。
Immerを利用しない場合、ToDoアプリケーションにおけるtodos
というStateのデータ更新は以下のようになります。
import { ActionTypes } from "../actionTypes";
import { TodoActions } from "../actions";
import { TodoState } from "../types";
const initialState: TodoState = {
allIds: [],
byId: {},
};
let nextTodoId = 0;
const todos = (state = initialState, action: TodoActions) => {
switch (action.type) {
case ActionTypes.ADD_TODO: {
const { content } = action.payload;
let id = ++nextTodoId;
return {
...state,
allIds: [...state.allIds, id],
byId: {
...state.byIds,
[id]: {
id,
content,
completed: false,
},
},
};
}
case ActionTypes.TOGGLE_TODO: {
const { id } = action.payload;
return {
...state,
byId: {
...state.byIds,
[id]: {
...state.byIds[id],
completed: !state.byIds[id].completed,
},
},
};
}
default:
return state;
}
}
export default todos
Immerを導入すると以下のように書き換えられます。
import { createSlice } from "@reduxjs/toolkit";
import { TodoState } from "../types/state/todos";
let nextTodoId = 0;
const initialState: TodoState = {
allIds: [],
byId: {},
};
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo(state, action) {
const content = action.payload;
let id = ++nextTodoId;
state.allIds.push(id);
state.byId[id] = {
id,
content,
completed: false,
};
},
toggleTodo(state, action) {
const id = action.payload;
const todo = state.byId[id];
todo.completed = !todo.completed;
},
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
上記の違いをみてわかる通り、Immerを利用することでオブジェクトの浅いコピーが不要になったためスプレッド演算子の記述がなくなり、コードが簡潔になりました。
後者の実装はnishina555/reduxtoolkit-todoappで公開しています。
なお、Redux ToolkitにはImmerが内包されているため、Redux Toolkitを利用している場合は、はじめから後者の記述方法が利用可能です。
また、後者のコードに出てくるcreateSlice
はActionType、ActionCreator、Reducerをまとめて定義できるRedux ToolkitのAPIです。
Redux ToolkitのAPIの詳細解説はReact Reduxのチュートリアル(Todoアプリ)をRedux Toolkitで書き換えてみたで紹介しています。
ReduxでImmerを利用したデータ更新をする際の注意点
直接オブジェクトの値を更新する場合、Reducerの戻り値はvoid(なにも返さない)にします。
一方新しく生成された配列やオブジェクトが更新後のデータになる場合、Reducerの戻り値は新規作成データにします。
上記のルールに従ったデータ更新の例は以下の通りです。
// https://redux-toolkit.js.org/usage/immer-reducers#mutating-and-returning-state
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
// Stateを直接更新しているのでなにも返さない
state.push(action.payload)
},
todoDeleted(state, action.payload) {
// 新しく生成された配列を返す
return state.filter(todo => todo.id !== action.payload)
}
}
})
詳細はRedux公式ドキュメントのRedux『Mutating and Returning State』を参照してください。
さいごに
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!## さいごに