ReduxもMobXも合わなかったので状態管理ライブラリを自作してみた話

公開:2021/01/10
更新:2021/01/10
5 min読了の目安(約5200字TECH技術記事

はじめに

Reactを始めて数か月ですが、状態管理ライブラリの選定に血迷ってしまって、結局自作したというはなしです。

アウトプットをこちらに載せておきます!

Github
NPM
デモ

よろしければ、Twitterを始めたのでフォローと、Githubに☆をしてくださると励みになります!

背景

Reactを始めてまず思ったのが状態管理ライブラリ多すぎ、非同期するのに追加のミドルウェアもいるの??
という印象でした。

Vueの場合はほとんどの場合Vuexを選択することになるし、TypeScriptとの相性は悪いものの、Vuexは非同期を前提としているのでそれだけで完結できるので余計な気はあまり使わないような気がします。
Reactの場合はどうでしょう? 
まず事実上のデファクトスタンダードのReduxをはじめとして、Flux、Mobx、Recoilなど、無数に存在します。
非同期をするなら、redux-saga、redux-thunkなどこれまたミドルウェアもたくさん存在します。

Vuexと比べたReduxの印象は「記述量多すぎてめんどくさい」、「余計な記述が増えてかえって可読性悪くならないか?」という印象でした。TypeScriptとの相性も良くないし、switch文で分岐するのもなんだかなーという感じ。
Redux ToolkotやRecoilを使えば上記のデメリットを解消できますが、私はオブジェクト指向やDIを多用するので引っかかるものは感じていました。

そこで次の選択肢として、MobXが上がりました。MobXはオブジェクト指向をサポートし、まさに魔法のようにリアクティブを実現してくれます。
ルールの強要もほとんどないので本当に自由に実装できます。
最初はこれだ!と思ってうれしく使ってました。
ただ、これはこれでブラックボックスを作りやすくなるので、デバッグがしにくかったり複数人で利用すると秩序が保たれない可能性が出てきます。(本当にわがままなやつですよね)

要するに、オブジェクト指向で、記述が楽で、かっちりしたルールのある状態管理ライブラリが欲しいんです!
→じゃあ自作するかという流れでした。

そんなこんなで、VuexやElmの良い部分を取り込んで単一方向データフローでオブジェクト指向で記述できる状態管理ライブラリを作成しました。
規模がそこそこ小さい場合や、迅速に単一データフローのアプリを作りたい場合は自分でも積極的に使っていきたいです。

成果物の紹介

早速ですが紹介します。

ライブラリのルール

  • 状態は常に読み取り専用にしましょう
  • 状態を変更するにはActionをDispatchしましょう
  • ディスパッチされたActionを処理するすべてのMutationは、Actionに期待される変更と組み合わされた古い状態を反映する新しい状態を作成します
  • UIは新しい状態を使用して表示をレンダリングします

※ここでのActionの概念はVuexを参考にしたためReduxとは少し違います

実装

例としてフィボナッチ数列でカウントアップするカウンタを実装してみます。

デモ

インストール

yarn add relux.js react-relux

または

npm install --save relux.js react-relux

まずは管理したい状態の型を作成

export interface FibState {
    n: number;
    count: number;
}

フィボナッチ数を生成するクラスを作成

export class FibonacciService {
    public fib(n: number) {
        if (n < 3) return 1;
        return this.fib(n - 1) + this.fib(n - 2);
    }
}

状態を更新するためのActionを作成

Actionを作成するにはAction<TState, Tpayload> クラスを継承する必要があります. クラス名がアクション名となります. TStateには状態の型, TPayoadにはペーロードの型を指定してください。 ActionをDispatchした際、invokeメソッドがコールされます.

クラス名がアクション名となりますが, Webpackや静的サイトジェネレータなどで圧縮されて元のクラス名がわからなくなる場合は, name プロパティを定義することでアクション名を上書きできます.

import { Action, Feature } from "relux.js"
import { FibState } from "./FibState";
import { FibonacciService } from "./FibonacciService";

export class IncrementalFibonacciAction extends Action<FibState, undefined> {
    // コンストラクタ引数の定義
    // デコレーターが使える場合はクラスに@Injectableデコレーターを付与するとこのプロパティを定義しなくても自動で解決される
    static parameters = [FibonacciService];
    name = "IncrementalFibonacciAction ";

    constructor(private readonly fibService: FibonacciService) { 
        super();
    }

    public invoke(_: undefined): Feature<FibState> {
        return async ({ dispatch, mutate, state }) => {
            if (state.n < 40 === false) {
                return;
            }

            // ここで非同期処理も可能
            await new Promise(resolve => setTimeout(resolve, 1000));

            const fib = this.fibService.fib(state.n + 1);

            // ここで状態を更新する
            mutate(s => ({
                ...s,
                count: fib,
                n: s.n + 1
            }));
        };
    }
}

Storeインスタンスの作成

1つの状態ツリーを機能ごとに複数に分割することができます。複数のアクションと1つの状態の組み合わせは、スライスと呼ばれます。

import { createStore } from "relux.js";
import { IncrementalFibonacciAction } from "./IncrementalFibonacciAction";
import { FibonacciService } from "./FibonacciService";

export const store = createStore({
    slices: {
        counter: {
            ...
        },
        fib: {
            name: "fib",
            actions: [
                // 先ほど定義したAction
                IncrementalFibonacciAction,
            ],
            // 初期値
            state: {
                n: 0,
                count: 0
            }
        }
    },
    // DIするサービスを登録
    services: [
        // フィボナッチ数を生成するクラス
        FibonacciService
    ]
});

// slice.stateの型を集約して1つの状態ツリーの型を生成します
export type RootState = ReturnType<typeof store.getState>;

React Component

useObserverでほしい状態を取り出して、useDispatchの戻り値を利用してActionをDispatchしています。

import { Provider, useDispatch, useObserver } from "react-relux";
import { store, RootState } from "./store";
import { IncrementalFibonacciAction} from "./IncrementalFibonacciAction";
 
export default () => {
    return (
        <Provider store={store}>
            <FibCounter />
        </Provider>
    );
};

function FibCounter() {
    const dispatch= useDispatch();
    const counter = useObserver((s: RootState) => s.fib);

    function increment() {
        dispatch(IncrementalFibonacciAction, undefined)
    }

    return (
        <div>
            <h1>Fibonacci counter</h1>
            <button onClick={increment}>Compute</button>
            <p>Compute Fibonacci number when N {"<"} 40</p>
            <div>Fib: {counter.count}</div>
            <div>N: {counter.n}</div>
        </div>
    );
}

おわりに

もしも気に入れば、使っていただけたらなと思います!

Github
NPM
デモ