flux/ReduxのStoreの持ち方について思う事

fluxもReduxもどうデータを持つかについてはあまり多く説明されてない気がします。もちろんアプリケーションによって持ち方は異なるとは思うんですが、自分用のメモとして改めて考え方を整理しておきたいと思います。

Store分割の単位:画面 vs ドメインモデル

Storeを画面毎に分割すべきかドメインで分割すべきか否かという議論があると思います。個人的にはドメインモデル毎に分割して整理すべきだと考えています。過去にfluxでフルSPAを一人で開発した時は最初はある程度画面毎にStoreを分割してたんですが、すぐきつくなってきてドメインモデルで分割し直しました。

画面毎に分割すると画面が増える度にStoreを増やすことになります。しかし画面が増える度にドメインの概念やデータの種類が増えるわけではないので、この場合明らかに重複した無駄なコードを書いてる事になると思います。

もちろんアプリケーションの種類や規模・作り方によると思います。たとえばphpMyAdminのようなものをflux/Reduxで作るのなら画面単位でStore を分割しても自然だと思います。また小さいアプリケーションであればそもそも画面数が少ないのであまり気にならないと思います。また、昨年開発していたプロジェクトはそこそこの規模でしたが基本的にjQueryで対応できるページはReact/Reduxを使わなかったので、SPAのページが飛び石で独立しており、その場合は画面単位の分割で必要十分でした。

なので、状況によります。ただ、言えるのは少なくともフルSPAでDBのテーブルが10個以上ならドメインで分割していかないと開発上のスピードも出なくなると思いますし、メンテナンスやリファクタリングにも必要以上に苦労する事が多くなると思います。

直近のプロジェクトでの反省

Storeの設計は熟考が必要なので手を動かすより先に考える時間が必要でもどかしく感じる事があるかもしれません。最初から手を止めて考えるのは心理的に難しいですし、また特にプロジェクト開始当初はドメインが安定してなかったりして、見極めも簡単ではなかったりします。そんな事もあって、直近参加してるプロジェクトでは画面単位で分割する形で走り出し今ドメインモデルで見直す時に来ています。

個人的な方針はドメインでの整理なのですが、自分も下記の点がまだ未整理で今回あまり強く言えなかったなというのが反省点です。

  • Store上でどこまでデータを正規化・共通化するのか?
  • 画面都合の情報はどこで管理するのか?

量的なバランスが関係してくると思うので唯一の正解はないと思いますが、以下もう少し頭の整理をしておきたいと思います。

Store上でどこまでデータを共通化・正規化するのか?

たとえばよくあるのは画面によって一覧表示用の複数のデータが欲しい場合と詳細表示用に単数のデータが欲しい場合があって、どのようにStoreでデータを持つのかというのがあると思います。考えられる選択肢は下記でしょうか。

  1. 一覧と詳細はデータ量が違うので別のキーで情報を保持する(e.g. employees, detail_employee)
  2. リストで常に一元的に保持する(e.g. employees)

1はStoreを一時的なキャッシュとして考えているアプローチで、2はよりDB的にデータとして考えているアプローチと言えるかもしれません。1の場合は画面によって使うデータを変えることになるため暗黙知が増えてしまうかもしれません。その画面用のデータなのに不用意にグローバルアクセスできるのも少し気になります。2の場合は実装コストが高くなります。たとえば詳細画面で参照するのにわざわざリストのindexを特定して情報を更新し、リストからfindして情報を表示するという格好になります(実際のプログラミングモデルはreducer等に分割されているのでちょっと違いますが)。この実装コストを敬遠して1を選択するというパターンがあるかもしれません。

Store上のデータはできるだけ一元的に管理する

ぼくの場合はどちらかというと2のアプローチです。アーキテクチャとしてStoreに一元的にまとめてるのにStoreの中で分散してたら意味が無いので本質的に同じデータはなるべく寄せるようにします(フルSPAは特にそうという話でもちろん状況によって変わると思います)。ただし、実装コストの問題があるので、もしこれが気になるようであれば、リストではなくオブジェクトを使ってKVS的に管理すると比較的簡易になるかもしれません。以下具体例です。

たとえば、/employeesという一覧ページと/employees/1という詳細ページを表示する場合ですが、リストの場合は下記のようになると思います。下記はmapStateToPropsのイメージです。

// 一覧画面
export default connect(state => ({
  employees: state.employee.employees,
}), dispatch => ({
  employeeActions: bindActionCreators(EmployeeActions, dispatch),
}))(App);

// 詳細画面
export default connect(state => {
  const employee = state.employee.employees.find((e) => {
    return e.id === employee_id;
  });
  return {
    employee,
  };
}, dispatch => ({
  employeeActions: bindActionCreators(EmployeeActions, dispatch),
}))(App);

次にオブジェクトの場合です。

// 一覧画面
export default connect(state => ({
  employees: state.employee.employees.sort((a, b) => (a.id - b.id)),
}), dispatch => ({
  employeeActions: bindActionCreators(EmployeeActions, dispatch),
}))(App);

// 詳細画面
export default connect(state => ({
  employee: state.employee.employees[employee_id],
}), dispatch => ({
  employeeActions: bindActionCreators(EmployeeActions, dispatch),
}))(App);

オブジェクトの場合、一覧画面でsortするのが面倒ですが、むしろ並び順が明示的になるのでリストに比べると実はデメリットは少ないです。次にreducerでどうなるかですが、リストの場合は下記のようになると思います。詳細を取得した時に面倒に感じるかもしれません。

export default handleActions({
  [TestActionTypes.EMPLOYEE_LIST_RESPONSE]: (state, {payload}) => {
    return {
      ...state,
      employees: payload,
    };
  },
  [TestActionTypes.EMPLOYEE_DETAIL_RESPONSE]: (state, {payload}) => {
    const employees = state.employees ? [...state.employees] || [];
    const idx = employees.findIndex((e) => e.id === payload.id);
    employees[idx] = payload;
    return {
      ...state,
      employees,
    };
  },
}, {
  employees: []
});

オブジェクトの場合は下記のようになると思います。リストで取得した時にオブジェクトに変換する必要がありますが、reduceで済むのでそこまで分かりにくくはならないと思います。

export default handleActions({
  [TestActionTypes.EMPLOYEE_LIST_RESPONSE]: (state, {payload}) => {
    const employees = payload.reduce((carry, e) => {
      carry[e.id] = e;
      return carry;
    }, {});
    return {
      ...state,
      employees,
    };
  },
  [TestActionTypes.EMPLOYEE_DETAIL_RESPONSE]: (state, {payload}) => {
    return {
      ...state,
      employees: {
        ...state.employees,
        [payload.id]: payload
      },
    };
  },
}, {
  employees: {}
});

おすすめとしてはオブジェクトで持つほうが良さそうに感じています。簡単に上記をまとめると一覧用でも詳細用でもなるべく同じ場所にデータを格納しておいたほうが良さそう。ただ、リストよりオブジェクトのほうがKVS的で個別のデータへのアクセスが簡単にできるしリストにするのも簡単なのでオブジェクトで持つのがおすすめという話でした。

もちろん気持ち悪さはある

ちなみにこの場合、一覧用に取得したデータと詳細用に取得したデータで情報量に差が出るのに同じ場所で管理するのが気持ち悪いという議論があるかもしれません。ただ、画面遷移時に情報取得をするアプリケーションであれば表示前に更新されるのでこの差異が問題になることは基本的にないはずです。Storeはキャッシュではあるのですが、単なるキャッシュというよりはクライアントサイド全体で共通認識が必要なデータなので、少なくとも場所は一元的に決めておくというのが重要だと感じています。

あるいはもし差分が出るのが気になるのであれば正規化を加えて分割して保持するという考え方もありだと思います。たとえば、CompanyとEmployeeというモデルがあった時にそれぞれを別のオブジェクトとして分けて同じreducerの中で管理していくというアプローチです。たとえばCompanyがEmployeeを持つこともあるし、EmployeeがCompanyを持つこともある。実際にAPIで取得する際にそのようなデータ構造で取得されると思うのですが、そのままたとえばcompaniesオブジェクトにCompanyがemployeesを持ったまま保持するのではなくcompaniesとemployeesにきっちり仕分けて更新をかけていく。こうしていくとデータの持ち方として情報量の差分は少なくなると思います。

ちなみにこれも程度の問題でバランス感覚と決めが必要そうですね。CompanyとEmployeeくらいはっきり分かれてたら分けるけど、Employeeに紐付くEmployeeSaralyHistoriesは正規化せずに腹持ちするとか。このあたりの考え方はなんとなくmongoDBのコレクションの設計を彷彿される気がします。KVS的というか。そう考えると、やはりStoreはキー・バーリューで持つのがますます自然な気がしてきました。

画面都合の情報はどこで管理するのか?

fluxの場合は特にComponentのstateを使うことは普通でしたが、Redux以降はComponentのstateではなく、Storeを唯一の状態として考えるようになってきてます。ドメインモデルのデータはもちろんそうすべきだと思いますが、画面のフラグ等の状態管理についてどうするかは議論が分かれるところのように感じています。Storeへは当然Actionを介してデータを反映していきますが、その画面都合の情報の数だけActionを作っていると開発が破綻してしまいます。

ちなみに公式に下記のFAQがあります。「ReactのsetStateは使ってもよいのか?」という質問です。

Do I have to put all my state into Redux? Should I ever use React’s setState()?

Using local component state is fine.

Componentのstateを使うのも良しとされています。Storeに入れる場合は何のために入れるのかをもう少し明確にしたほうが良いかもしれません。

Componentのプライベートな画面情報

個人的にはこれに関しては2つのアプローチがとれると考えています。

  1. 画面の状態など完全にコンポーネントに閉じたものはComponentのstateを良しとする
  2. 画面状態をstateに反映する汎用的な仕組みを作る・導入する

上記いずれのアプローチでもドメインモデルのデータと混ぜてしまうとやはり暗黙知が増えてしまいそうなので避けるべきだと思います。そのために1ではStoreとは全く別の場所(Componentのstate)で情報を管理するというアプローチで、2は汎用的なアクションを介してStoreの中のドメインデータとは別の場所で管理するというアプローチです。

個人的には1推しです。ポリシーさえ明文化されて徹底されていればプライベートな情報ですし影響はないはずと思います。2に関しては公式のFAQによるとredux-ui, redux-component, redux-react-localがありますが、どれもあまり普及していません。なので、今回はredux-formを使ってサンプルを作ってみました。まだ実際に使ってないですが、redux-form使ってるプロジェクトでStoreに絶対入れるポリシーであれば、こんな感じもありかな…と思いました。

example-redux-form

こちらのサンプルではredux-formを利用して画面の状態管理を行っています。たとえば下記のようにredux-formのchange関数を利用してフラグを更新します。

  onClickButton(e) {
    const {change, formValues} = this.props;
    change('__isOpen', !formValues.__isOpen);

    // it can keep object too.
    change('__values', {
      ...formValues
    });
  }

そして、上記のデータはconnectの段階でformValuesをpropsとして受け取るようにしておけばComponentの中からアクセス可能になります。

export default connect((state) => ({
  test: state.test,
  initialValues,
  formValues: getFormValues(FORM)(state) || initialValues,
}))(TestFormReduxForm);

下記のようにthis.props.formValues.__isOpenという形でアクセス出来ます。

  render() {
    const {pristine, invalid, submitting, handleSubmit, formValues} = this.props;
    return (
      <div>
        <button onClick={this.onClickButton}>{formValues.__isOpen ? 'close' : 'open'}</button>
        <div className={`${formValues.__isOpen ? '' : 'hidden'}`}>
          <form className="form" onSubmit={handleSubmit}>
            <Field
              component={compnentA}
              name="a"
              type="text"
            />
            <button className="btn btn-primary" disabled={pristine || invalid || submitting}>
              Submit
            </button>
          </form>

        </div>
      </div>
    );
  }

redux-formを使ってフラグ管理した場合の注意

redux-formを使ってフラグ管理した場合にその画面が本当にフォームだった時に__isOpenといったフラグまで実際に送信されてしまう事になりかねません。これを防止する方法としてはmiddlewareで整形する等の方法が考えられます。たとえばサンプルでは下記のようなmiddlewareを適用しています。

export const omitUnnecessaryFormValues = store => next => action => {
  let payload = action.payload;
  Object.keys(TestActionTypes).forEach((type) => {
    if (action.type === type) {
      const p = {};
      Object.keys(payload).forEach(key => {
        if (!/^__.+/.test(key)) {
          p[key] = payload[key];
        }
      });
      payload = p;
    }
  });
  action.payload = payload;
  return next(action);
};

ちょっとオレオレ感があるかもしれませんが、redux-formを導入しているプロジェクトであれば一石二鳥ではあるので悪くないかもと考えています。まだ実際の開発では使ってないのでもしかしたら何か問題があるかもしれませんが…。

Component間で共有すべき画面情報

代表的なものだと、たとえばローディング中状態を管理したり等があると思います。通常このような情報はたとえばview-reducerとかapp-reducer等を作ってそこで保持される場合が多いのかなと思います。これと同じで個々の画面の状態に関するものは画面用のエリアにドメインの情報とは分けて保持するのが良いように思いました。たとえばchat-view-reducerを作ってチャット画面のComponentで共有されるべき情報はそこで管理する等。その場合、chat-view-actionsというのも対応してあったほうが良いかもしれません。

あるいはあまりに数が増えすぎるようであれば、前述したような汎用的に書き換える仕組みがあっても良いかもしれません。

その他

今回、改めて確認しようと思って少し調べたら下記のStackOverflowがありました。「ReduxのStoreをドメインに従って構成すべきか、あるいは画面に従って構成すべきか?」という質問です。

Should I structure the Redux store after the domain or after the app views?

Domain, absolutely. Your store structure should totally reflect the actual data you’re working with.

「絶対ドメイン」と回答があります。実際、fluxの公式にもdomainというキーワードがあります。なので公式的にもドメインに従うべきというのが基本路線なんだと思います。ドメインで整理するのは決めてしまえばもう分かりやすいですね。ただ、サーバーサイドと異なりクライアントサイドでは画面の固有の状態も多く関係してきて、それらの画面固有の情報を管理する場所も同じくStoreになるため混乱が生じやすいと言えるかもしれません。このあたりの整理は唯一の正解はないですが、個人的には少なくともドメインと画面固有の情報は識別して明確に分かるようにしておくのが正解だと思うようになりました。

ちなみに下記のリンク集がこのStackOverflowにあったのですが、すごく良さそうなので読んでおきたいです。React/Reduxが出始めのころと比べるとかなりいろんな情報が出てるんですね。

React/Redux Links