はじめまして。株式会社スタメンでフロントエンドエンジニアをしている永井です。週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のメソッドにinjectReducer
とappendReducers
を追加していることです。
injectReducer
では、引数にkeyとreducerを受け取り、その受け取ったkeyとreducerをもとにして、先程作成したcreateReducer
の引数に渡します。
そして、storeのreplaceReducer
関数でReducerの変更を行っています。(replaceReducer
はカスタム関数ではなく、Reduxの関数です)
storeでinjectReducer
とappendReducers
を受け付けるために、既存の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
などが存在しますが、スター数が少ないことや、依存性を回避したい理由で、独自で実装しました。参考になれば幸いです。
スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています!興味を持ってくれた方は、ぜひ下記のエンジニア採用サイトをご覧ください。