【TypeScript】 Reactだけで型システムとの親和性を保ちつつシンプルに設計してみる

最近、勉強会に行くと、TypeScriptReact + Reduxの構成を取っているプロジェクトの話を良く聞きます。

ReduxやReactコミュニティのライブラリ群の型システムとの親和性 👻

私も業務ではRedux + TypeScriptの構成で仕事をすることが多いのですが、TypeScriptでReduxないしはReactアプリを作るにあたって、下記のように少し悩ましい型の問題があります。

  • 1: 複数のReactコミュニティのライブラリやReduxミドルウェアを組み合わせたときの型崩れないしは型情報の喪失 (Reducer、Action, 副作用周りと型システムとの親和性) 😪
  • 2: ライブラリーのアップデート由来の型崩れ 😢
  • 3: 複数ライブラリを組み合わせた時の型パズル 😨

特にReactコミュニティ周りのライブラリー (react-redux, redux, recompose, react-routerなど) を組み合わせたとき、その型整合性を取るのに一苦労でした。 また、HoC形式のライブラリーを組み合わせた結果に対して整合性を取ろうと、型パズルや、複雑な型操作が必要になったり、ライブラリーのアップデートで頻繁に型が壊れたりすることもしばしばで Reactアプリの動作とは直接関係ないところで調整に時間を食ってしまうことが多かったです。いっそのこと型定義ファイルを利用せず、自分で型定義を書こうかと悩みました。

Reduxも会社やチームの状況によっては良いと思うのですが、ReduxもMobxも使わずReactだけで型システムとの親和性を保ったままの構成でやってみるのもおもしろそうだなと思いました。

React以外を使用しないでTypeScriptによる型による支援を受けられるFluxライクな設計を作ってみる 🙂

そこで、一旦振り返りとしてもう一度、ReduxやMobXを使わないで、ReactだけでTypeScriptによる型システムの支援を受けられるシンプルなFlux (厳密にはActionにオブジェクトではなくクラスを使うなど、色々変更を加えています) を実装していこうと思います。作成したものは例のごとくシンプルなカウンターアプリです。

下記のような特徴を持っています。

  • 1: _バケツリレー_をしないで済む仕組みにする。
  • 2: Stateは Read Only であり、Actionを経由してStoreを更新する。
  • 3: 複雑な型操作を要求しない。
  • 4: HoCなど関数型の道具を多用しない。昔からある伝統的なクラスを使います。
  • 5: Stateを持つものはrecompose等を使わず クラス で定義する、それ以外はSFC (Stateless Functional Component) で定義する。

まずは全体像を見ておきましょう。

react_flux

幾つかの登場人物がいます。

  • 1: _Store_: それぞれの責務に応じてデータを管理するクラスです。
  • 2: Action_: _Store に対して State (データ) の変更を要請する要素です。下記の dispatch() の引数として呼び出されます。Actionpayload(ペイロード) という付加的なデータを持つことがあります。 Storeは reducer() メソッド内部で、このペイロードを元に自身の State を更新 します。 普通はJavaScriptオブジェクトなのですが、今回の設計ではクラスとして実装しています。
  • 3: Dispatch_: 上述の_Action をStoreに伝達するためのメソッドです。今回の設計では Store に実装されており、主にユーザーのクリックなどのアクションによって呼び出されます。
  • 4: Reducer_: 上述の _dispatch によって飛んできた Action に応じて、State をどう更新するかを定義する関数です。今回の設計では Store に実装されています。
  • 5: Container Component_: Storeから _Stateをsubscribe()してPresentational Componentにデータを渡す React Componentです。
  • 6: Presentationa Component : Containerからデータを受け取り Viewの表示 に専念するReact Componentです。

今回のアプリは下記の環境で作成しました。

  • TypeScript 3.1.3
  • React 16.4.16

作成したものは下記の環境に配置をしています。

ダウンロード

Github

下記コマンドで動作させることができます。

git clone git@github.com:AtaruOhto/react-typescript-redux-like.git
cd react-typescript-redux-like
yarn
yarn start

Storeを実装する 🍱

まずはアプリケーションの データ (State) を保持するStore をクラスとして定義します。

Storeの基底クラスである、Storeクラスを定義します。 このStoreを継承したサブクラスでは reducer() メソッドを実装します。 reducer()Actiondispatch されてきた時に、その Action に応じて 新しいState (データ) をStoreにセット するメソッドです。

ベースクラス (Store) の実装 🎁

/* Storeの基底クラス。 全てのStoreはこのクラスを継承 */

import { Action } from "./Action";

/*  Store (ベースクラス) */
export abstract class Store<T> {
  private state: T;
  private debugMode: boolean;
  private callbacks: Array<(val: T) => void> = [];

  constructor(state: T, debugMode: boolean) {
    this.debugMode = debugMode;
    this.state = state;
    this.exposeStoreToGlobal();
  }

  /* Containerへの購読機能を提供 */
  public subscribe = (callback: (val: T) => void) => {
    this.callbacks.push(callback);
  };

  /* Containerへの購読停止機能を提供 */
  public unsubscribe = (callback: (val: T) => void) => {
    this.callbacks = this.callbacks.filter(cb => cb !== callback);
  };

  /* 自身の持つStateを取得する*/
  public getState = () => this.state;

  /* Actionをdispatch()する*/
  public dispatch = (action: Action<unknown>) => {
    this.logActionHistory(action);
    this.next(this.reducer(this.state, action));
  };

  /* protected */

  /* dispatch()されたActionに応じて、自身のStateの更新方法を定義する。この抽象メソッドはサブクラスで実装する */
  protected abstract reducer(state: T, action: Action<unknown>): T;

  /* Private */

  /* 実際にStateを更新するとともに、購読しているContainerに変更があったことを通知する */
  private next = (state: T) => {
    this.state = state;
    this.notify(this.state);
  };

  /* debugModeの時にコンソールにActionの履歴とペイロードを出力する */
  private logActionHistory = (action: Action<unknown>) => {
    if (this.debugMode) {
      // tslint:disable-next-line:no-console
      console.log({
        payload: action.getPayload(),
        store: this.constructor.name,
        type: action.getType()
      });
    }
  };

  /* debugModeの時にwindowオブジェクトの下に自身を生やす*/
  private exposeStoreToGlobal = () => {
    if (this.debugMode) {
      (window as any)[this.constructor.name] = this;
    }
  };

  private notify = (val: T) => {
    this.callbacks.forEach(cb => {
      cb(val);
    });
  };
}

Store は下記の機能を持ちます。

  • 1: 自身の Stateを管理 する
  • 2: getState() を経由して自身のStateを取得させる
  • 3: dispatch()_を経由して飛んできた _Action に応じて自身のStateを更新する。 (_dispatch() でしかStateを更新できない_)
  • 4: React Container に対し自身を購読 (subscribe()_) させる機能を持つ。自身のStateが更新された時に、購読している _Container に対して自動で新しいStateを通知する
  • 5: debugModeの時は自身をwindowオブジェクトの下に生やす & ログにActionの履歴を出力する

ベースクラス (Store) を継承して個々のStoreを実装する 🗃️

各々のStoreは上記で定義したStoreクラスを継承します。

react store

それぞれのStoreは各々が管理するデータの責務ごとに作成します。 今回のようにカウンターの数を管理するStoreを作成する場合には、下記のように定義します。 下記の CounterStore クラスでは親クラスの 抽象メソッドである reducer() を実装 して、dispatch()メソッド経由で飛んできたAction に対して、自身のState (データ) をどのように更新するかの振る舞いを自分で定義 してあげます。

react reducer

下記ではIncrementCountがActionとしてdispatch()されてきた時には、countの数を一つ増やし、SetCount Actionがdispatch() されてきた時には、count にペイロードとして渡されてきた任意の値をセットします 結果的に違う型の値が返されるときには、コンパイルエラーで検知できます。

/* CounterStoreクラス カウンターの数を管理する責務を持つ*/

import { DecrementCount, IncrementCount, SetCount } from "src/actions/Counter";

import { Action } from "src/libs/Action";
import { Store } from "src/libs/Store";

export interface ICounter {
  count: number;
}

const initialState = { count: 0 };

/* Genericsで型を指定してStoreクラスを継承 */
class CounterStore extends Store<ICounter> {
  /* reducer以外のロジックをこのクラスには追加しない。 */

  /* reducerを実装する: 必ずICounter型を返すようにする。違う型が返される場合には、コンパイルエラーで検知可能。 */
  protected reducer(state: ICounter, action: Action<unknown>): ICounter {

      /* IncrementCountの場合には countを + 1する */
    if (action instanceof IncrementCount) {
      return {
        ...state,
        count: state.count + 1
      };
    }

    /* DecrementCountの場合には countを - 1する */
    if (action instanceof DecrementCount) {
      return {
        ...state,
        count: state.count - 1
      };
    }

    /* SetCountの場合にはcountを任意のペイロードの値に設定する*/
    if (action instanceof SetCount) {
      return {
        ...state,
        count: action.getPayload()
      };
    }

    /* 許可されていないActionが渡されてきた場合にはエラーを発生させる! */
    throw new Error(`An invalid Action is passed! "${action}" is invalid!`);
  }
}

const buildStore = () =>
  process.env.NODE_ENV === "production"
    ? new CounterStore(initialState, false)
    : new CounterStore(initialState, false);

export const counterStore = buildStore();

Actionを実装する 🍳

Actionの基底クラス を実装します。上記で実装した reducer() に投げられてくるActionは全て、このクラスのインスタンスです。 インスタンス生成時に ペイロードの型 (Actionに伴うデータの型) を指定することでTypeScriptから型の支援を受けることができるようになっています。

export class Action<P> {
  private type: string;
  private payload: P;
  private error: boolean;
  private meta: any;

  constructor(payload: P, error: boolean = false, meta: any = null) {
    this.type = this.constructor.name;
    this.payload = payload;
    this.error = error;
    this.meta = meta;
  }

  public getType = () => this.type;
  public getPayload = () => this.payload;
  public getError = () => this.error;
  public getMeta = () => this.meta;
}

export const createAction = <P>(payload: P): Action<P> => new Action(payload);

個々のアクションを定義する 🍇

クラス定義が並んで、エキセントリックに見えるかもしれませんが、個々のアクションは全てActionクラスのサブクラスで定義します。 これらの Action はStoreの dispatch() メソッドで呼び出されます。

import { Action } from "src/libs/Action";

// tslint:disable:max-classes-per-file

/* ペイロードで渡される値の型をGenericsで指定します */
export class IncrementCount extends Action<null> {}
export class DecrementCount extends Action<null> {}

/* 必ずnumber型のデータをペイロードとして受け取る。それ以外はコンパイルエラーを出力する */
export class SetCount extends Action<number> {}

Container Component を定義する 🥘

Viewの実装に移っていきます。 Viewの全体像を見ておきましょう。

react_container

Container Componentでは必要となるStoreを購読 (subscribe)します。購読を行うことで Store でデータが更新された時に自動的に Storeからの最新のデータを受け取れる仕組みになっています。ここでは「_Container Component_」を「_StoreとPresentational Componentをつなぐデータの渡し役を担う React Component_」として扱います。 Containerは下記の機能を持ちます。

  • マウント時に購読対象のStore (複数可能) のsubscribe()を開始する。
  • 破棄される時に購読対象のStoreへのsubscribe()を終了する。
  • 自身のStateをReact ComponentにそのままPropsとして渡す。
import * as React from "react";
import { CounterComponent } from "src/components/CounterComponent";
import { counterStore, ICounter } from "src/stores/Counter";

export class CounterContainer extends React.Component<{}, ICounter> {
  constructor(props: {}) {
    super(props);
    this.state = counterStore.getState();
  }

  /* コンポーネントがマウントされる時にsubscribeを開始する */
  public componentWillMount() {
    counterStore.subscribe(this.subscribeCounter);
  }

  /* コンポーネントが破棄される時にはsubscribeを放棄する */
  public componentWillUnmount() {
    counterStore.unsubscribe(this.subscribeCounter);
  }

  /* コンポーネントにそのまま自身のStateを渡す */
  public render() {
    return <CounterComponent {...this.state} />;
  }

  /* CounterStoreのsubscribe() 処理 */
  private subscribeCounter = (val: ICounter) => {
    this.setState({ count: val.count });
  };
}

Presentational Componentを定義する 🍮

「Presentational Component」は言ってみれば、Viewのレンダリングに専念する React Componentです。 「Container Component」から渡されてきたデータによって、見た目を変化させたり、ユーザーのアクション (clickなど) によって、Actionを dispatch() します。

react_flux
import * as React from "react";

import { DecrementCount, IncrementCount, SetCount } from "src/actions/Counter";

import { asyncDecrement, asyncIncrement } from "src/actionTasks/Counter";
import { counterStore, ICounter } from "src/stores/Counter";

// tslint:disable:jsx-no-lambda
export const CounterComponent = (props: ICounter) => (
  <div>
    <h1>{props.count}</h1>
    <div>
      <input
        type="button"
        value="INCREMENT"
        onClick={() => {
            /* InCrementCount Actionをdispatch ペイロードはnull  */
              counterStore.dispatch(new IncrementCount(null));
        }}
      />
      <input
        type="button"
        value="DECREMENT"
        onClick={() => {
         /* DeCrementCount Actionをdispatch ペイロードはnull  */
          counterStore.dispatch(new DecrementCount(null));
        }}
      />
      <input
        type="button"
        value="INCREMENT Async"
        onClick={() => {
          asyncIncrement();
        }}
      />
      <input
        type="button"
        value="DECREMENT Async"
        onClick={() => {
          asyncDecrement();
        }}
      />
      <input
        type="button"
        value="SET 100"
        onClick={() => {
          /* SetCount Actionをdispatch ペイロードは100  */
          counterStore.dispatch(new SetCount(100));
        }}
      />
    </div>
  </div>
);

これで一通り、完成し、TypeScriptによる型の支援が受けられる形でカウンターアプリを作成することができました。

下記に動作確認用のアプリケーションを配置しています。

ソースコードは下記からダウンロードすることができます。

Github

  • このエントリーをはてなブックマークに追加