【Redux】Immerを利用してState更新のイミュータビリティを保証する

JavaScript

前提知識: データ更新とイミュータビリティ

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)やってます。フォローしてもらえるとうれしいです!## さいごに

参考資料