Typescript & Redux & ImmutableJSでreducerを1行にする

May 11th, 2019

Introduction

このサイトでは一部Reduxを使用しているのですが、ちょっとやりづらさを感じたので思い切っていろいろ書き換えてみることにしました。 やりたかったのは

  • Reducerの冗長部分の解消
  • stateやpropsの型推論

の2つです。 ということで今回はImmutableJSによるModelの導入とTypescriptの導入をやってみました。

書き換え前の実装

書き換え前の段階ではredux-actionsでaction creatorやreducerを実装していました。 一部ソースを載せるとこんな感じです。

import { createActions, handleActions } from 'redux-actions'
import { nameValidate } from '../utils/contactValidater'

// actions
const {
  user: {
    name,
  },
} = createActions({
    NAME: {
      UPDATE: value => value,
    },
})

export const updateName = name.update

// reducer
const reducer = handleActions(
  new Map([
    [
      updateName,
      (state, { payload }) => {
        const error = nameValidate(payload)
        return {
          ...state,
          name: {
            value: payload,
            error,
          },
        }
      },
    ],
  ]),
  initialState,
)

export default reducer

action creatorはシンプルですがreducerはちょっと長いですね。。。 実装中はpayloadの渡し方をミスってnameにstring渡しちゃったりしたこともありました。

ちなみにちょっと脱線するんですが、上記のようにぼくはactionとreducerをまとめて記述するDucksという設計を取っています(最近知りました)。 やってみると結構Reduxのサンプルとかでよくあるredux-wayという手法と比べ、いちいちファイルを跨がないから非常にわかりやすかったです。

ImmutableJSでReduxにModelを追加する

ImmutableJSはReact同様Facebookが作ってるライブラリで、Immutableなデータ構造を提供します。 具体的には、List, Stack, Map, OrderedMap, Set, OrderedSet, Recordなどを提供します。

先述のわかりづらいreducerからどうにかしようということで、このImmutableJSのRecordを継承してModelを作ることにしました。 ReduxにおけるModelはstateの生成ロジックを担い、reducerは交通整備のみに責務を集中する形になります。 また一部省略してますがソースを乗っけるとこんな感じになりました。

import { Record } from 'immutable'
import { createActions, handleActions } from 'redux-actions'
import { nameValidate } from '../utils/contactValidater'

// model
const UserRecord = Record({
  name: {
    value: '',
    error: '',
  },
})

class UserModel extends UserRecord {
  updateName(value) {
    const error = nameValidate(value)
    return this.withMutations(mut => mut.setIn(['name', 'value'], value).setIn(['name', 'error'], error))
  }
}

// actions
const {
  user: {
    name,
  },
} = createActions({
  USER: {
    NAME: {
      UPDATE: value => value,
    },
  },
})

export const updateName = name.update

// reducer
const reducer = handleActions(
  new Map([
    [
      updateName,
      (state, { payload }) => state.updateName(payload),
    ],
  ]),
  new UserModel(),
)

export default reducer

これにより

  • reducerが短くなった
  • reducerの戻り値の型ロジックをRecordに移譲できた
  • payloadの適用ロジックがみやすくなった

というメリットを受けることができました。 ちなみに、Record.withMutationsを使ってるのは、複数の処理を挟む時にこのやり方が一番早いのでそうしました。 他のメソッドだといちいち内部的にRecordをnewして返却しちゃうので、複数の値をいじるような場合には要注意です。

Typescriptでstateの型推論をする

これでreducerは綺麗になったけど、まだ問題が残ってます。 そう、stateの型推論がしたい。

ということで、ここからTypescriptに書き換えようと思ったんですが、どうもredux actionsはtypescriptで書きずらい。。。 なかなか推論してくれなくてキャストしたりしなきゃいけなかったり、Interfaceを引き回して最終的なstateの型を自前で定義することになったり、、、

まぁこの辺は普通に僕の調査不足でredux actionsでも普通に楽にかけるのかもしれませんが、どうやら調べてたらtypescript使うならredux-actiontsよりtypescript-fsaっていうライブラリがいいらしい。 reducerのロジックはModelに分離したおかげで変更もそこまでかからなそうだし、typescript-fsaでさらにreduxを書き換えてみました。

import { Record } from 'immutable'
import actionCreatorFactory from 'typescript-fsa'
import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { nameValidate } from '../../utils/contactValidater'

// model
export interface IUserMember {
  name: {
    value: string;
    error: string;
  },
}

const UserRecord = Record<IUserMember>({
  name: {
    value: '',
    error: '',
  },
})

class UserModel extends UserRecord {
  updateName(value: string) {
    const error = nameValidate(value)
    return this.withMutations(mut => mut.setIn(['name', 'value'], value).setIn(['name', 'error'], error))
  }
}

// action
const actionCreator = actionCreatorFactory()

enum ActionType {
  UpdateName = 'USER/NAME/UPDATE',
}
  
export const updateNameAction = actionCreator<string>(ActionType.UpdateName)

// reducer
const reducer = reducerWithInitialState(new UserModel())
  .case(updateNameAction, (state, payload) => state.updateName(payload))

export default reducer

Mapの代わりにreducerWithInitialState.case内でreducerロジックを書くことになっただけで、そこまで変わらなかったです。 強いて言えばredux actionsのcreateActionsの書き方のように、action名をstringでいちいち記述しない方式がすごい好きだったので残念ですが、 代わりにenumを使えるようになったので結果としてはまぁいっかという感じです。

これで準備は整ったので、あとはstate生成やcontainer conpomentで型推論が効くようになれば完璧です!

combineReducersで型推論させたい

Reduxの公式にもあるんですが、stateの設計時にdomainやui,appなど上位で責務わけしちゃうほうがいいよー、って考え方があります。 このサイトでもやってるのですが、上記サンプルだとcombineReducersを複数回挟むと型推論が消えます。 ですがcombineReducersに型変数を渡してあげればいい感じに推論してくれるようになります。

import { combineReducers } from 'redux'
import user, { IUserMember } from './modules/app/user'

interface UserState {
  user: IUserMember
}

const app = combineReducers<UserState>({ user })

const root = combineReducers({
  app,
})

export default root

container componentで型推論させたい

上記までいけばcontainer componentのmapStateToPropsで推論させるのはもう簡単です。 TypescriptのReturnTypeを使ってstore.getStateの型を取得するだけです。

type AllState = ReturnType<typeof store.getState>

const mapStateToProps = (state: AllState) => ({
  name: state.app.user.name,
})

簡単ですね。

感想

typescript-fsaのgithubのスターが結構少なめだったからちょっと不安だったけど、普通にめっちゃ便利でした。 やっぱstateの推論までしてくれると非常に便利ですね。

ただGatsbyとTypescriptがそこまで相性が良くないというか、、、 トランスパイル時にエラー吐いてくれないんですよね。 ちょっとそこは残念ですが、まぁ別にターミナルにエラー吐かないだけでIDEがめっちゃ怒ってきてくれるから使うメリットはあるかなと思います。

だから結論としてはTypescript + Webstormが最強ってことで…ww