yo_waka's blog

418 I'm a teapot

flux-utilsをReact Hooksで地道にリファクタしていく

グローバルなJSONリファクタ に続き、地道なリファクタシリーズ第2弾。

Facebook社が公開している flux-utils、使っていますか。
今は大抵の人がReduxでFluxを実装していることでしょう。flux-utilsは3,4年前はシンプル故に普通に選定してもよいと思える選択肢でした。
でも今は選定しないほうがいいです。

選定しないほうがいい理由はいくつかあって、

  • 提供されているConainerコンポーネント、提供されているビルド済JS内でコンストラクタをnewを使わずメソッドコールしているため、WebpackでES Moduleを指定したビルドができない(CommonJSを指定しないといけない)
  • Facebook社が使っていないためかReactの最新バージョンに追随したバージョンがnpmにリリースされるまでのスピードが恐ろしく遅い
  • fbjs, fbemitterというこれまたFacebook社内でおそらくもはや使われていないだろうライブラリに依存していて、こいつらが同様にnpmにリリースされない(fbjsなんかはもう1年以上アップデートされていない)

というわけで、脱flux-utilsです。

といってもいきなり全部React Hooksで置き換えるぞ!というのも規模が大きなアプリケーションでは難しいので、まずは上に挙げたクリティカルな問題を孕んでいるContainerを置き換えていくのがよいと思います。

こんな感じで、2つReduceStoreをsubscribeしたContainerコンポーネントがあるとする。

// @flow

import React from 'react';
import { Container as FluxContainer } from 'flux/utils';
import { countUp } from './flux/actions/counter_action';
import { addTask } from './flux/actions/task_action';
import CounterStore from './flux/stores/counter_store';
import TaskStore from './flux/stores/task_store';

type Props = {};
type State = { count: number, tasks: Array<Task> };

class Container extends React.Component<Props, State> {
  static getStores() {
    return [CounterStore, TaskStore];
  }

  static calculateState() {
    return { ...CounterStore.getState(), ...TaskStore.getState() };
  }

  handleAdd(e: SyntheticEvent<>, task: Task) {
    e.preventDefault();
    addTask(task);
  }

  handleCountUp(e: SyntheticEvent<>) {
    e.preventDefault();
    countUp();
  }

  render() {
    return (
      <>
        <div>
          <Form onSubmit={this.handleAdd} />
          <List tasks={this.state.tasks} />
        </div>
        <Counter count={this.state.count} onCountUp={this.handleCountUp} />
      </>
    );
  }
}

export default FluxContainer.create(Container, { withProps: true });

このFluxContainerがやってることは、要はHOCを作ってgetStoresで取れるReduceStoreに対してaddListenerしているだけ。
これはReact Hooksの useEffectuseReducer を使って置き換えることができる。

こんな感じのhooksスクリプトを用意する。

// @flow

import { useEffect, useReducer } from 'react';

const useFluxStores = (stores, reducer, deps = []) => {
  const [state, dispatch] = useReducer(reducer, reducer());

  useEffect(() => {
    const tokens = stores.map(store => store.addListener(() => {
      dispatch(store);
    }));
    return () => tokens.forEach(token => token.remove());
  });

  return state;
};

export { useFluxStores };

あとは先程のContainerコンポーネントSFCにしてhooksを呼び出してあげればいい。

// @flow

import React from 'react';

type Props = {};

const getStores = () => [CounterStore, TaskStore];

const calculateState = () => {
  return { ...CounterStore.getState(), ...TaskStore.getState() };
};

const handleAdd = (e: SyntheticEvent<>, task: Task) => {
  e.preventDefault();
  addTask(task);
};

const handleCountUp = (e: SyntheticEvent<>) => {
  e.preventDefault();
  countUp();
};

const Container: React.FC = (_props: Props) => {
  const state = useFluxStores(getStores(), calculateState);
  return (
    <>
      <div>
        <Form onSubmit={handleAdd} />
        <List tasks={state.tasks} />
      </div>
      <Counter count={state.count} onCountUp={handleCountUp} />
    </>
  );
};

export default Container;

FluxContainer依存が無くなったのと、SFCになったことでだいぶシンプルになった。
React-Routerなんかを使っていて、Containerコンポーネントにpropsが渡ってくる場合でもちゃんと動く。

こうしておくと、以降のリファクタがしやすくなる。

fbemitterに依存しているReduceStoreも外していく場合

  1. StoreのgetInitialState、reduceメソッドをstaticメソッドに変更
  2. ContainerコンポーネントuseReducer(Store.reduce, Store.getInitialState) とする
  3. ActionCreatorにdispatchメソッドを渡せるようにしてFacebook fluxのDispatcherと置き換える

こんな感じで依存を外せるはず。
Containerコンポーネント単位でflux-utils依存を外せれば、Reduxでいうところの 3 principles も実現しやすくなるはず。