ReduxのreducerコードをDRYに書く

イタンジ株式会社の中村です。エンジニア兼プロダクトマネージャーをしています。 最近はもっぱら仕様を書いたりフロントエンドを書いたり不動産会社様の業務の知見を深めたりしています。

今回は、Redux Toolkit を使いつつreducerのboilerplateを減らし、コードの見通しをよくする方法について書いていきます。

とても素直に書く

例えば以下のようなstateがあります。

type State = {
  key1: string
  key2: number
}

このとき、stateのpropertyを更新するreducerのコードをとても素直に書くと、

  • key1を更新するaction
  • key2を更新するaction

などを個別に定義することになり、冗長なコードになってしまうと思います。

type ChangeKey1 = {
  value: State['key1'] // string
}

type ChangeKey2 = {
  value: State['key2'] // number
}

一つのactionで済ませる

次に思いつくのは一つのactionで更新する場合ですが、Actionの型が以下のようになっていると、型パラメータの Key を媒介して、

  • key = 'key1' の場合は、valueの型がstring
  • key = 'key2' の場合は、valueの型がnumber

のような関係性を型のレベルで表現してstateを更新する関数を一つにまとめることが可能です。

type ChangePropertyAction<Key extends keyof State> = {
  key: Key
  value: State[Key]
}

型パラメータはT,S,Uなどアルファベット一字で表現する流派もあると思いますが、複数あると読んでいて混乱するので自明な場合以外は意味のある名前をつけています。

actionCreatorの定義を共通化する

ここまででもコードをだいぶ減らせますが、Reduxを使う場合、基本的には直交する単位でstateを分割すると思います。 stateが複数ある場合に毎回定義するのDRYではないです。

reducerおよびactionCreatorを生成するためのライブラリ 、 Redux Toolkit と組み合わせて、 最終的なコードは以下のようになります

// actionCreatorを定義するユーティリティ関数
import { PayloadAction, Draft } from '@reduxjs/toolkit'

export const generateActionCreator = <
  TState,
  TWritableKey extends keyof Draft<TState> = keyof Draft<TState>
>() => {
  const changeProperty = <Key extends TWritableKey>(
    state: Draft<TState>,
    action: PayloadAction<{
      key: Key
      value: Draft<TState>[Key]
    }>
  ): Draft<TState> => {
    const { key, value } = action.payload
    // ここがimmutableではないのは意図的なもので、ライブラリの内部で利用されている Immer の設計による
    state[key] = value
    return state
  }

  return { changeProperty }
}

// ユーティリティ関数を使うクライアントコード
import { createSlice } from '@reduxjs/toolkit'

const generator = generateActionCreator<State>()

const { actions, reducer } = createSlice({
  name: 'nameOfReducer',
  initialState,
  reducers: {
    changeProperty: generator.changeProperty,
    initialize: () => initialState,
  },
})

プロジェクトでは、actionCreatorに渡す関数をユーティリティ側に定義しています。 代案としてはcreateSliceの実行をライブラリ側で行うという選択肢もあると思いますが、 その方法だとそれぞれのstateでユースケースごとの個別のactionを記述するときに困ります。 TypeScriptの都合で、@reduxjs/toolkitのcreateSliceの引数のreducersにリテラルで記述したpropertyでないと、 戻り値のactionsのpropertyとして型推論されないからです。

ちなみに Redux Toolkit を導入した動機は、ライブラリを何も使わずに書くのと比べて、

  • actionCreator
  • reducerに書いていたactionを受け付けてstateを更新する関数

の2つをコード上近くに書けるからです。 上記は大体同時に読み書きするので、actionCreatorとreducerを個別に定義するとかなり目が滑るため可読性に直結します。

余談

reducerのコードを書く時、objectをimmutableに更新するのがセオリーです。 その観点で以下の再代入が気になった方もいるかと思います。

state[key] = value

これは、Redux Toolkitが内部で利用している Immer の設計上の都合によるものです。 複雑な構造を持ったstateの場合、immutableに更新するのが冗長なので、ユーザ側ではmutableに更新しても、reducerの関数としては新しいstateのobjectを返すようになっています。

// nestが深いとこのようにimmutableに更新するのが冗長になってしまう
const new = { ...old, [key]: value }

最後に

プロジェクトではnestされたpropertyを更新する関数など、他にも色々な(高階関数化された)actionCreatorが定義されています。 stateのデータ構造に規律を持たせたら、もっと便利にできそうです。