Help us understand the problem. What is going on with this article?

redux-actionsを用いて、reduxのactionCreator縛りを行う

More than 1 year has passed since last update.

reduxのお作法は色々ありますが、うちのチームで使ってる記法を紹介します。

うちのチームでは、redux-actionsを利用して、actionとreducerの定義を行なっています。Typescriptを導入しているので、型補完が効くように使うapiを制限しています。

actionsの定義

actionは全てboundActionCreatorを通してdispatchします。理由は単純に生のactionを書くのは発火場所の特定がしやすくなるメリットよりも、型を指定できず、可用性の点で不便となることが多いからです。以下ユーザー定義の例です。

User/actions.ts
import { createAction } from 'redux-actions'
import { actions as Identity } from './Identity'

const nameSpace: 'User' = 'User';

export const SET_USER_ID = `${nameSpace}/SET_USER_ID`

const setUserId = createAction(SET_USER_ID)

export default {
  setUserId,
  ...Identity
}

以上の例では、極端ですが、例をわかりやすくするためパラメータ1個のreducerと、多数のパラメータを持つIdentityのReducerを分割しています。このIdentityが持つactionsは以下のようになっています。

User/Identity/actions.ts
import { createAction } from 'redux-actions'

const location = 'User'
const nameSpace: 'Identity' = 'Identity'

export const SET_LAST_NAME = `${location}/${nameSpace}/SET_LAST_NAME`
export const SET_FIRST_NAME = `${location}/${nameSpace}/SET_FIRST_NAME`
export const SET_LAST_NAME_PHONETIC = `${location}/${nameSpace}/SET_LAST_NAME_PHONETIC`
export const SET_FIRST_NAME_PHONETIC = `${location}/${nameSpace}/SET_FIRST_NAME_PHONETIC`
export const SET_GENDER = `${location}/${nameSpace}/SET_FEMALE_GENDER`
export const SET_BIRTH_DAY = `${location}/${nameSpace}/SET_BIRTH_DAY`
export const SET_MAIL = `${location}/${nameSpace}/SET_MAIL`
export const SET_PHONE = `${location}/${nameSpace}/SET_PHONE`
export const SET_IMAGE = `${location}/${nameSpace}/SET_IMAGE`
export const SET_PREFECTURE = `${location}/${nameSpace}/SET_PREFECTURE`
export const SET_ADDRESS = `${location}/${nameSpace}/SET_ADDRESS`
export const SET_ADDRESS_BUILDING = `${location}/${nameSpace}/SET_ADDRESS_BUILDING`

const setLastName = createAction(SET_LAST_NAME)
const setFirstName = createAction(SET_FIRST_NAME)
const setLastNamePhonetic = createAction(SET_LAST_NAME_PHONETIC)
const setFirstNamePhonetic = createAction(SET_FIRST_NAME_PHONETIC)
const setGender = createAction(SET_GENDER)
const setBirthDay = createAction(SET_BIRTH_DAY)
const setMail = createAction(SET_MAIL)
const setPhone = createAction(SET_PHONE)
const setImage = createAction(SET_IMAGE)
const setPrefecture = createAction(SET_PREFECTURE)
const setAddress = createAction(SET_ADDRESS)
const setAddressBuilding = createAction(SET_ADDRESS_BUILDING)

const actionCreators = {
  setLastName,
  setFirstName,
  setLastNamePhonetic,
  setFirstNamePhonetic,
  setGender,
  setBirthDay,
  setMail,
  setPhone,
  setImage,
  setPrefecture,
  setAddress,
  setAddressBuilding,
}

export default actionCreators

createActionsを使わずにcreateActionを使う理由は、ActionTypeを個別にexportして使う場合に、actionCreatorsのkeyがcomputed Propertiesで指定することになり、補完が効かなくなるからです。createActionの場合、keyに対して、actionCreatorの型が正しくつくので、この記法に統一することにしました。上の例では後述のhandleActionで型をつける方法で、reducerを作っているため、サボっていますが、実は、creatActionは 第二引数で、関数によってactionCreatorとpayloadのマッピングをどのように行うか指定することによって、actionCreatorの引数の型と戻り値の型をつけることができます。第三引数も同様にactionCreatorの引数とmeta属性のマッピングができます。(ただしこちらは、引数の数が整合性を取らないと、うまく型がつかないため、明示的に型パラメータをつけるか、型拡張を行って推論を有効にする必要があります。)

たとえば、

example.ts
import {createAction} from 'redux-actions'
const setLastName = createAction(SET_LAST_NAME,(arg1:string) => ({value:arg1}))

と書いた時、setLatNameはsetName:(arg1:string) => Action<{value:string}>という型推論が走ります。(実際は@types/redux-actionsに定義されているActionFunction1のような名前で型がつきます。)

locationは対象のStateが保存されているdirectory名、nameSpaceはState名というルールを敷くことで、同名のactionTypeが確実に存在しないようにしています。actionCreatorの名前が被ってしまったかつ、同じコンポーネントで使わなければいけない場合には、react-reduxのmapDipatchToPropsで調節します。

以上のように多すぎるパラメータ構造を扱う場合には、適切な粒度でstateを分割して定義するようにしています。ディレクトリで分ける場合は、stateをネストしてもしなくてもどちらでもいいことにしています。NoSQLDBを使う場合は、一つのレコードに属性がたくさんついていたほうが都合がいいこともあるので、一度に引っ張れるものに関してはネストをしない方が使い勝手が良いため極力排除しています。ネストする場合は、特に、同時にデータをfetchする可能性がないものに関して、別々のデータ構造に分けることも検討します。

reducersの定義

reducerは、原則に基づいて全てのkeyに対応するreducerを定義し、combineReducersまたは、spreadOperatorのどちらかを用いてstateを結合します。combineReducersの使い方については、今後のAdvent Calendarに書くつもりなので興味のある方は参考にしてください。(今回の場合はIdentityのstateを全てUser stateとしてidと同じ階層にを配置したいので、combineReducersを行う前にobjectを結合しています。)

User/Identity/reducers.ts
//...略
export const reducers: Reducers<ProfileTypes.Identity> = {
  firstName,
  firstNamePhonetic,
  gender,
  birthDay,
  image,
  lastName,
  lastNamePhonetic,
  mail,
  phone,
  prefecture,
  address,
  addressBuilding,
};
User/reducers.ts
import { User } from 'types';
import reduceReducers from 'reduce-reducers';
import { combineReducers } from 'redux';
import { Action, handleActions } from 'redux-actions';
import * as actionTypes from './actions';
import * as Identity from './Identity';

export const initialState: User = {
  id: '',
  ...Identity.initialState,
};

type UserPayload = User[keyof User];

const id = handleActions<User['id'], UserPayload>(
  {
    [actionTypes.SET_USER_ID]: (state, { payload }: Action<User['id']>) => payload!
  },
  initialState.id
);

const userStateReducers = combineReducers({
  id,
  ...Identity.reducers,
});

const user = handleActions<User, Partial<User>>(
  {
    [actionTypes.SET_PROFILE]: (state, { payload }: Action<User>) => ({ ...state, ...payload! })
  },
  initialState
);

export default reduceReducers(user, userStateReducers);

reduceReducersは馴染みがない方もいらっしゃるかもしれませんが、同じstateを扱うreducerを結合して、評価式を結合させるものです。つまり、同じstateに対して別々のaction.typeで発火する複数のreducerを結合して、一つのreducerであるかのように振舞うことができます。

reduceReducerPsuedoCode.ts
const reducer1 = (state =0,action) => {
  if(action.type == '1') return 1
  return state;
}

const reducer2 = (state=0,action) => {
  if(action.type == '2') return 2
  return state;
}

const reducer = reduceRedcuer(reducer1,reducer2)

/** same
*   const reducer2 = (state=0,action) => {
*     if(action.type == '1') return 1
*     if(action.type == '2') return 2
*     return state;
*   }
**/

handleActionsはジェネリック型で3つの型パラメータをとり、 を指定できます。この第二型パラメータに少し癖があり、payloadの型は常にstateの型に一致してる必要性がないにも関わらず、なんらかのパターンで指定しなければいけないのですが、これは、actionCreatorのみでしかdispatchを許容しないという制約をつければ、actionCreatorの戻り値の型で決定されるので、reducer側からきつい縛りを入れる必要が無い限りは、これを用いた方が可用性が上がります。

よってうちのチームでは型拡張を使って、redux-actionsの定義を足しています。

redux-actions.d.ts
import { ActionFunctions, ActionFunction1, ReducerMapMeta,ReducerMeta,ActionMeta } from 'redux-actions';

declare module 'redux-actions' {
  export function combineActions(...actionTypes: Array<ActionFunctions<any> | string | symbol>): any;
  export type ActionMaps<ActionCreators extends {[key:string]:ActionFunctions<any>}> = {[key in keyof ActionCreators] : ReturnType<ActionCreators[key]>}
  export type Actions<ActionCreators extends {[key:string]:ActionFunctions<any>}> = {[key in keyof ActionCreators] : ReturnType<ActionCreators[key]>}[keyof ActionCreators]
  export type Payloads<ActionCreators extends {[key:string]:ActionFunctions<any>}> = ReturnType<ActionCreators[keyof ActionCreators]>['payload']
}

以上の定義をしておくと、以下のように型推論の起点を全てstate,およびactionからの動的推論に置き換えることができて、縛りすぎて、毎回変更が起きることを防げます。ちなみに、combineActionsの戻り値をanyのオーバーロードで置き換えてるのは、combineActionsの戻り値はtoStringを持つFunctional InteraceなのでcomputedPropsを使えるのですが、Typescriptではこの型定義をサポートしておらずそのままの状態ではtoString()を実行させる必要があるのですが、any型であれば、computedPropertiesとして一応入力可能であると認識してくれるので、その記述の面倒を省くためです。

example.ts
handleActions<typeof initialState, Payloads<typeof actions>>({
  [actionTypes.SET_HOGE]: (state,action:ActionMaps<typeof actions['setHoge']>) {
    // ...略
    return state
  },
  [combineActions(...values(graduationActions))]: (state, action: Actions<typeof actions>) => {
    return state.map((value, index) => {
     if (index === action.index) {
       return _stateReducer(value, action)
     }
     return value
    })
  }
})

export の定義

定義したreducerをrootReducerに組み込む、actionCreatorをComponentに組み込む、initialStateを各ロジックに組み込む必要があるので、index.tsで、exportする物を調整します。

User/index.ts
export { default as actions } from './actions';
export { default as reducers, initialState } from './reducers';

全てのStateに対して必要なexportは全て上記に、まとまるので、あらゆるstate  でindex.tsはコピペすることができます。また、redux-sagaを使ってる場合は、これらのactionを普通のactionであるかのように使うことができるため、ここでマージします。

User/index.ts(改)
import { sagaActions } from './sagas'
import {default as _actions } from './actions'
export const actions = {..._actions, ...sagaActions}
export {
  default as reducers,
  initialState
} from './reducers'
export {default as sagas} from './sagas'

sagaについても、actionとrootSaga用のexportは分けてexportできるようにすると良いです。冗長なexportが発生しますが、treeShakingと可用性はトレードオフなので、可用性を重視した構成を作っています。

component側の型定義

本来componentとreduxは互いに依存する必要がないので、例えば先ほどのsetBirthDay ActionCreatorをコンポーネントのPropsに埋め込む場合、

components/Sample.tsx
interface Props {
  ...//略
  setBirthDay (date: Date): void;
}

const Sample = (props:Props) => (
  <BirthDay onChange={(value)=> setBirthDay(value)} />
)

export default connect({user}=>({user}),actions)(Sample)

みたいな実装を行い、setBirthDayについては、reduxのactionCreatorだけでなく他propsの代入によっても使い回せるようにDI性を保持しとくのが理想的ではあります。ただ、このUIをreduxと密結合させても良いと思う割り切りを持つと、型定義をサボることができます。うちのチームの直近のルールではステートはほぼアプリケーションの構造に依存しているため、どちらでも良いことにしました。つまり、以下のように型定義を省略させることができます。

components/Sample.tsx
import {actions} from 'Users'

interface Props {
  ...//略
  setBirthDay: typeof actions['setBirthDay'];
}

const Sample = (props:Props) => (
  <BirthDay onChange={(value)=> setBirthDay(value)} />
)

export default connect({user}=>({user}),actions)(Sample)

これはredux-actionsのcreateActionで作られたActionCreatorにはちゃんと戻り値のactionと引数の型がつくため、valueの型のミスマッチがあると、警告してくれます。

まとめ

今回は型定義に弱いと言われるReduxですが、actionによってreducerの型構造が支配される構造を作る工夫をするためにredux-actions使うと結構良い感じで型がつくというお話をしました。類似の型補完ライブラリにtypescript-fsaというライブラリがありますが、こちらは、アプローチとしては直感的で良いのですが、isTypeを用いるという制約がわずらしく感じられたので採用しませんでした。redux-actionはtoString()インターフェースを持つ関数の型推論がcomputedPropsの入力で行えたとするなら、その時点で、actionの引数に型を明示しなくても推論できるので、最強かと思うのですが、現状のtypescriptではtoString()ファンクショナルインターフェースのcomputedPropertiesの型推論をサポートできないようなので、以上が現実解かと思います。いつも目にするActionTypeをユニオンで列挙する型づけにはいつも疑問を持っていて、巨大なアプリケーション制御を行う時には破綻すると思い、今回の手法を紹介してます。もし何かご意見あればお気軽にコメントください。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away