Dynamic Reducerの実装方法

redux

はじめまして。株式会社スタメンでフロントエンドエンジニアをしている永井です。週5で筋トレをしています。

弊社のプロダクトであるTUNAGでは、フロントエンドをReact、Redux、TypeScript、サーバーサイドをRuby on Railsで実装しています。

今回の記事ではReduxのReducerを動的に読み込ませる実装方法について書きたいと思います。

前提として、React、Reduxをある程度理解している人向けに書いています。

目次

はじめに

動的なReducer(Dynamic Reducer)の読み込みとはどういうことかを説明します。

ReduxにおけるReducerを読み込ませるタイミングはcreateStoreでstoreを作成する時です。

通常はload時にcreateStore関数が実行され、そのcreateStore関数の引数に、必要なReducerを返すcombineReducers関数を渡すことで、storeが作成されます。 一方で動的なreducerの読み込みでは、読み込ませるReducerを必要なタイミングによって追加します。

なぜ動的に読み込ませることが必要なのでしょうか?

1つの理由としては、読み込み速度を上げることです。 各ページで必要なReducerは共通して使用できるものもありますが、各ページでしか使われないものも沢山あります。各ページで必要なReducerを必要なページ、タイミングで読み込ませることで、一括でReducerを読み込ませるよりも少ない読み込み量を実現することができます。

また、アプリケーションが肥大化するにつれて、combineさせるReducerの数も多くなるので、アプリケーション全体としての複雑性を回避する上でも有益な手段とも言えます。

動的なReducerの実装に関して調べていると、Reduxの作者であるDan Abramovがstack overflowで回答していたものがありました。しかし、彼の回答では「Reducerを追加するタイミング」や「TypeScriptでの実装」を知ることができなかったので、その回答をベースにして、より詳細な実装を今回試みました。

主なフレームワークとライブラリ

  • React: 16.8.6
  • redux: 4.0.4
  • react-redux: 7.1.0
  • react-router: 5.0.1
  • typescript: 3.5.3

実装手順

全体的な実装イメージですが、ベースとなるReducerを最初のload時にcombineReducersで読み込ませ、必要なタイミングでそのページに必要なReducerを追加していくイメージです。

※ 実装する際は、動的にReducerが読み込まれているか確認するために、Redux Dev Toolを使うことをおすすめします。

Reducer

最初にどのページでも必要なReducerをcombineReducersにまとめます。ここではどのページでも読み込まれるReducerをlayoutReducer、今後追加されるReducerをhomeReducerとしています。

import { ReducersMapObject } from 'redux'
import { createBrowserHistory } from 'history'
// 省略

export const history = createBrowserHistory()

export interface RootState {
  router: RouterState,
  home: HomeState,
  layout: LayoutState
}

const createReducer = (appendReducers: ReducersMapObject[]) =>
  combineReducers({
    router: connectRouter(history),
    layout: LayoutReducer,
    ...appendReducers,
 })
 
 export default createReducer

ここのcreateReducer関数では、combineReducersによって読み込ませるReducerオブジェクトを作成しています。そして、その引数にappendReducersとして、動的に追加する対象のReducerを渡し、combineReducersに追加します。

storeの作成

import { Store, createStore, Reducer, ReducersMapObject } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { routerMiddleware } from 'connected-react-router'
import { createBrowserHistory } from 'history'
import createReducer from '.'
import { RootState } from './'

export type AppendKeyType = keyof RootState

export interface ExtendedStore extends Store {
  appendReducers?: ReducersMapObject
  injectReducer?: (key: AppendKeyType, reducer: Reducer) => ExtendedStore
}

const middleware = // 必要なミドルウェア追加

const initializeStore = () => {
  const store: ExtendedStore = createStore(createReducer(), middleware)
  // storeにreducerを追加する
  store.injectReducer = (key: AppendKeyType, reducer: Reducer) => {
    store.appendReducers = {}
    store.appendReducers[key] = reducer
    const { appendReducers } = store
    store.replaceReducer(createReducer(appendReducers))
    return store
  }

  return store
}

export default initializeStore

先程定義したcreateReducerと必要なmiddlewareを、store作成時、つまりcreateStoreの引数に渡します。

上のコードではinitializeStoreという関数を作成して、その関数内でcreateStoreを行っています。

ここで特徴的なのはstoreのメソッドにinjectReducerappendReducersを追加していることです。

injectReducerでは、引数にkeyとreducerを受け取り、その受け取ったkeyとreducerをもとにして、先程作成したcreateReducerの引数に渡します。

そして、storeのreplaceReducer関数でReducerの変更を行っています。(replaceReducerはカスタム関数ではなく、Reduxの関数です)

storeでinjectReducerappendReducersを受け付けるために、既存のstoreの型をExtendedStoreとして拡張しています。

Router

これで動的なReducerを受け付ける準備ができました。しかし、先程作ったinjectReducerをどこで実行するかが問題です。 それを行うのは、Routerで行います。例えば、Routerの/homeで指定しているcomponentが呼び出されるタイミングで、必要なReducerをinjectReducerで注入します。 例として、/homeで必要なReducerをHomeReducerとします。

const Router = (props: Routerprops) => {
  return (
    <Switch>
      <Route 
        exact
        path="/home"
        render={props => <HomeRouter {...props} />}
      />
    </Switch>
  )
}

export default Router
import HomeContainer from '../../containers/home'
import HomeReducer from '../../reducers/home'

const HomeRouter = (props: HomeRouterProps) => {
  
  return <HomeContainer {...props} />
}

export default withReducer('home', HomeReducer)(HomeRouter)

ここで、新しくwithReducerが出てきました。withReducerの実装は以下の通りです。

import React from 'react'
import { Reducer } from 'redux'
import { useStore } from 'react-redux'
// import types
import { ExtendedStore, AppendKeyType } from './initializeStore'

const withReducer = (key: AppendKeyType, reducer: Reducer) => (
  WrappedComponent: (props: HomeRouterProps) => JSX.Element
) => {
  const Extended = (props: HomeRouterProps) => {
    const store: ExtendedStore = useStore()
    if (store.injectReducer) {
      store.injectReducer(key, reducer)
      return <WrappedComponent {...props} />
    }
    return null
  }
  return Extended
}

export default withReducer

withReducerでは引数にkeyとreducerを受け取ります。そして返り値の関数の引数に描画するコンポーネントを指定します。

返り値の関数で行っていることは、react-reduxのuseStore()でstoreを持ってきて、先程定義したinjectReducerで引数で渡ってきたreducerとそのkeyをstoreに注入しています。 こうすることで、HomeRouterが描画されたタイミングで必要なReducerの注入を行うことが可能になります。

実際に、/homeにアクセスすると、homeReducerが追加されていることをRedux Dev Toolで確認できると思います。

まとめ

今回は動的なReducerの実装方法について書きました。最初にこの実装方法を見た時に、一回では理解できませんでしたが、全体感を掴むことで理解することが出来ました。

また、既存のstoreを拡張するにあたって、reduxがどのようにstoreを実装しているかをコードレベルで調べることで、reduxの構成も深く理解することができたと思います。

動的Reducerですが、npmとしてもredux-dynamic-reducerなどが存在しますが、スター数が少ないことや、依存性を回避したい理由で、独自で実装しました。参考になれば幸いです。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています!興味を持ってくれた方は、ぜひ下記のエンジニア採用サイトをご覧ください。

スタメン エンジニア採用サイト

フロントエンドエンジニア募集ページ