Reduxをenjoyableにする

この記事は[フラーAdvent Calendar 2019](https://adventar.org/calendars/4155)の15日目の記事です。

# はじめに

[airpc](https://github.com/shinyoshiaki/airpc) という [Redux Toolkit](https://github.com/reduxjs/redux-toolkit) のようなReduxのロジック周りのコード量を減らすためのライブラリを作ってみたので、それについての記事を書いてみました。

## 素のRedux

素のReduxだとActionCreatorでActionを作って、ActionをReducerにくぐらせて状態を更新しています。
ActionCreatorとReducerの簡単な例を書いてみます

```typescript
type State = {
  some: string;
};

const initialState: State = { some: "" };

export const actionCreator = ( data: string ) => ({
  type: "action" as const,
  payload: data
});

type Actions = ReturnType<typeof actionCreator>;

export default function reducer(
  state = initialState,
  action: Actions
): State {
  switch (action.type) {
    case "action": {
      return {
        ...state,
        some: action.payload
      };
    }
    default:
      return state;
  }
}

```

ただ、stateを操作したいだけなのに ```actionCreator``` と```reducer``` のswitch文中のロジックの2通りを書かないといけません。面倒ですね。

## enjoyableなRedux (by airpc)
そこでReduxのActionCreatorとReducerを一体化させて、もっと楽に書けるように```airpc```を使ってReduxのロジックを書いてみましょう

```typescript
import { withRedux } from "airpc";

type State = {  
  pinned: string;
};

const initialState: State = { some: "" };

const [actions, reducer] = withRedux(
  class Hogehoge {
    constructor(public state: State) {}
    
    someMethod = (pinned: string) => ({ ...this.state, pinned });
  },
  initialState
);

export default reducer;
export { actions };

```
ActionCreatorとReducerが消えた代わりに、HogehogeというクラスがwithReduxという関数の引数内で宣言されている奇妙なコードが出てきました。
state操作のロジックはHogehogeクラス内のメソッドとして書かれており、先程の素のReduxに比べるとコード全体がスッキリしたような印象があるんじゃないかと思います。
また、withRedux関数は、actionsとreducerの2つの値を返しています。
actionsはActionCreatorの集合体のようなものです。
reducerは普通のreducerなので、combineReducerやcreateStoreなんかにそのまま食わせられます。

それではwithRedux関数についてもう少し詳しく見ていきましょう。

## withRedux

withReduxの第1引数にはstateを操作するクラスを渡します。このクラスのことを便宜上ロジッククラスとでも呼びましょう。ロジッククラスは次のルールに従います。

***ロジッククラス***

|   項目     |ルール|
| -------------- | ----------------------------------------------------------- |
| クラス名       | 自由                                                        |
| コンストラクタ | 引数に ```public state:State``` のみを必ず取る              |
| メソッド       | 任意の長さの引数を取れる。必ず、State型のオブジェクトを返す |
| プロパティ     | プロパティを持つことは禁止されている                        |

withReduxの第2引数にははstateの初期状態を渡します。

withRedux関数内部ではロジッククラスを元にロジッククラスと同じメソッドを持ったクラスのインスタンスを生成し、それをactionsとして返しています。ですので、このactionsをactionCreatorとして使う際には以下のようにdispatch関数内でactionsのメソッドを実行すればOKです

```typescript
import {actions} from '../modules/hogehoge'

dispatch(actions.someMethod("hi"));
```

簡単ですね！

## どうやって実現しているのか？

airpcのソースコードを覗いてみましょう

[src/redux.ts](https://github.com/shinyoshiaki/airpc/blob/develop/src/redux.ts)

```typescript
import { ActionCreator, ValidState } from "./typings/action";

class WrapRedux {
  constructor(target: any) {
    const subscribe = (type: string) => {
      const actionType = target.name + "_" + type;
      (this as any)[type] = (...args: any[]) => ({ type: actionType, args });
    };
    Object.keys(new target()).forEach(type => {
      if (type === "state") return;
      subscribe(type);
    });
    Object.getOwnPropertyNames(target.prototype).forEach(type => {
      if (type === "constructor") return;
      subscribe(type);
    });
  }
}

function exposeRedux<T extends any>(instance: T) {
  const update = (state: any, v: { type: string; args: any }): T["state"] => {
    const { type, args } = v;
    const [name, method] = type.split("_");
    if (instance.constructor.name !== name) return state;

    if (instance[method]) {
      instance.state = state;
      return { ...state, ...instance[method](...args) };
    } else {
      return state;
    }
  };
  return update;
}

export function withRedux<A extends any, B>(
  target: { new (state: B): A },
  initialState: B
): [
  Omit<ActionCreator<A, Required<B>>, "state">,
  (
    state: B | undefined,
    action: Omit<A[keyof A], "state">
  ) => ValidState<B, A["state"]>
] {
  const instance = new target(initialState);

  const methods = new WrapRedux(target) as any;
  const update = exposeRedux(instance);

  const reducer = (state = initialState, action: any) => update(state, action);

  return [methods, reducer];
}

```

airpcのwithRedux関数はWrapReduxとexposeReduxの2つのクラスと関数から成り立っています。

まずWrapReduxの方から見ていきましょう

***WrapRedux***

```typescript
class WrapRedux {
  constructor(target: any) {
    const subscribe = (type: string) => {
      const actionType = target.name + "_" + type;
      (this as any)[type] = (...args: any[]) => ({ type: actionType, args });
    };
    Object.keys(new target()).forEach(type => {
      if (type === "state") return;
      subscribe(type);
    });
    Object.getOwnPropertyNames(target.prototype).forEach(type => {
      if (type === "constructor") return;
      subscribe(type);
    });
  }
}
```
WrapReduxでは要するに、ActionCreatorをクラスの情報から自動生成しています。
コンストラクタで引数にwithReduxの第1引数のクラスを受け取り、
引数のクラスの持つメソッドに対応するActionCreatorをWrapReduxクラスのメソッドとして生成しています。

次にexposeReduxを見ていきましょう

***exposeRedux***

```typescript
function exposeRedux<T extends any>(instance: T) {
  const update = (state: any, v: { type: string; args: any }): T["state"] => {
    const { type, args } = v;
    const [name, method] = type.split("_");
    if (instance.constructor.name !== name) return state;

    if (instance[method]) {
      instance.state = state;
      return { ...state, ...instance[method](...args) };
    } else {
      return state;
    }
  };
  return update;
}

```
exposeReduxは要するに、dispatchされたActionに対応するクラスのメソッドを実行し、新しいstateの作成を行っています。
exposeReduxの引数はwithReduxの第1引数のクラスのインスタンスとなります。
exposeRedux内のupdate関数はreducerとなります。update関数では、dispatchされたactionの値に該当するクラスインスタンスのメソッドにactionの値を渡して実行し、戻り値を新しいstateとしています。

あとは```withRedux```関数でWrapReduxとexposeReduxのセットアップをして、actionsとreducerを返しています。
ユーザはreducerを普通にcombineReducerなんかに食わせてactionsをdispatch関数内で実行すればOKです。

# あとがき

今回作ったairpcは一応テストは書いているものの、誰のレビューも受けていない信頼性の低いライブラリなので、プロダクションでReduxのactionCreatorとReducerをどうにかしたい場合は普通に[Redux Toolkit](https://github.com/reduxjs/redux-toolkit) を使ったほうが良いと思います。

自分でこういったライブラリを作ってみるのもそれはそれで勉強になります。特にライブラリの型安全性を実現するためにConditionalTypesを駆使するあたりは結構勉強になりました!（本記事では触れてないけど...）