LoginSignup
2
4

More than 3 years have passed since last update.

MobXでVuexっぽい構成のストアを作る

Last updated at Posted at 2019-12-07

はじめに

この記事は React #2 Advent Calender 2019 の7日目の記事です

この記事は、今までVue + VuexやNuxt.jsで開発をしていたんだけれど、新たにReactでの開発をしなくてはいけなくなった人向けに、MobXで、使い慣れたVuexっぽいストアを作成するための記事です。
特に「Reduxとかよくわからないよ〜使いたくないよ〜 :;(∩´﹏`∩);:」って人向けです。

MobXって、機能が薄いせいもあって、ちゃんとしたプロジェクトで使うにはある程度設計を考えなくてはいけないんですが、じゃあどういう設計にすればいいのかの情報が(少なくとも日本語では)全然ないので、そこに対する回答の一つになればなと思って書きました。MobXはいいぞ

今回紹介する設計が、絶対に正しい!ってわけではもちろんないんですが、ストアの名前なども含めてかなりVuexを意識してあるので、ReactでもVuexっぽいストアが使いたいって人は参考にしてもらえると嬉しいです。

サンプルプロジェクトは以下に置いています。
https://github.com/kuwabataK/my-mobx-test-prj

MobXとは

MobxはReactのストアライブラリの一つです。Reduxとの最大の違いは、ストアの値をミュータブルに変更することを許容している点です。
これにより、Reducerなどを書く必要がなく、Vuexのようにより簡単な構成のストアを作成することができます。とてもVuex的ですね。
ミュータブルなVuexからイミュータブルなReduxに移行するのは、考え方の違いも含めて、なかなかに大変ですが、MobXを使うことで、Vuexでの知識を活かしながら開発を行うことができます。

設計方針

Vuexっぽいストアにするために、以下のような設計方針でストアを作っていきます。

  • 単一方向のデータフローでストアの状態を更新させるために、 configure({ enforceActions: "always" }) を設定しておく。これを有効にしておくことで、@actionをつけたメソッド以外の場所で値の更新を行うことができなくなる(VuexのMutation相当の機能を作れる)
  • 設計をシンプルにするため、デコレータを使ったクラスベースの書き方でStoreを記述する
  • 一つのRootStoreの下に複数のModuleを登録する形の、ツリーの構造のストアにする
  • Vuexをやってた人のために、mapStatemapActionsを用意しておく

順番にやっていきましょう。

基本設計

なにはともあれ、とりあえず動く最小設計のストアを作ります。
今回の題材は、Vuexの公式でも紹介されているよくあるカウンターアプリです

以下のように、store.tsCounterModule.tsを作成します。

.
├── App.css
├── App.tsx
├── index.css
├── index.tsx
├── store
│   ├── CounterModule.ts   // 追加
│   └── store.ts           // 追加
store.ts
import { CounterModule } from "./CounterModule";
import { configure } from "mobx";
import React, { useContext } from "react";

configure({ enforceActions: "always" });   // enforceActionsをalwaysにすることで、Vuexのように@actionの外で@observableな値が変更された場合に、エラーを発生させることができる

export class RootStore {
  counterModule = new CounterModule(); // counterModuleを登録
}

export const store = new RootStore(); // Storeのインスタンスを作成
export const storeContext = React.createContext(store); // storeのcontextを作成
export const useStore = () => useContext(storeContext); // コンポーネントでstoreを簡単に読み込むためのカスタムフックを作成

CounterModule.ts
import { observable, action, computed } from "mobx";

/**
 * Mobxのカウンターストア
 */
export class CounterModule {
  /**
   * カウント
   * (Vuexのstate)
   */
  @observable count = 0;

  /**
   * カウントの2倍の値を返します
   * (Vuexのgetters)
   */
  @computed
  get doubleCnt(){
    return this.count * 2
  }

  /**
   * カウントを増やす
   * (VuexのMutation)
   */
  @action
  increment() {
    this.count += 1;
  }

  /**
   * カウントをへらす
   * (VuexのMutation)
   */
  @action
  decrement() {
    this.count -= 1;
  }
}

また、コンポーネントの中でストアを利用できるようにするためにApp.tsxを以下のように書き換えます。
これにより、Appの下に登録したコンポーネントの中で、Context経由で、Storeを取得することができるようになります。

App.tsx
import React from "react";
import "./App.css";
import { storeContext, store } from "./store/store";

const App: React.FC = () => {
  return (
    <storeContext.Provider value={store}>   // Providerを追加
      <div className="App">
      </div>
    </storeContext.Provider>
  );
};

export default App;

store.tsの中身を見てみましょう。 store.tsの中で作成した RootStore がこのストアのルートストアになります。今はこのストアの下にcounetrModuleというストアを登録しています。下の3行はStoreインスタンス化とContextへの登録を行っています。

CounterModule.tsが今回のメインです。ここにVuexで言うところのstate getters mutation actionを登録していきます。
Vuexでは、オブジェクトリテラルの形式でそれぞれのプロパティを定義し、その下に各stateやgettersを登録していきましたが、MobXはクラスベースなので、ここの変数やメソッドに@hogehogeの形でアノテーションをつけて、それぞれのクラスのメンバが何を表すのかを定義します。

@observable

@observableはVuexのstateに対応する機能です。クラス変数の頭にこれをつけると、この変数の変更をMobXが検知してくれるようになり、変更に従って、ReactのDomを更新してくれるようになります。

class Hoge {
   @observable fuga = 'fugafuga'    // 変更検知可能なプロパティ
}

@computed

@computedはVuexのgettersに対応する機能です。get構文の頭に@computedをつけると、変更検知可能なゲッターを作成することができます。
また、変更がない場合はVuexと同じように、計算を行わずにキャッシュされた値を返してくれるので計算コストを抑えることができます。

class Hoge {
   @observable fuga = 'fugafuga'

   // 変更検知可能なgetter
   @computed          
   get foo(){
      return this.fuga + 'bar'
   }
}

@action

@actionはVuexのmutationに対応する機能です(ややこしいですがactionではありません)。configure({ enforceActions: "always" })の設定を行っている場合は、@actionをつけたメソッドの外で状態を変更するとerrorが発生するようになります。

class Hoge {
   @observable fuga = 'fugafuga'


   // observableな値を変更可能なメソッド
   @action
   setFuga(fuga){
      this.fuga = fuga
   }
}

また、Vuexと同じく、@actionの中で非同期処理を行うことはできません。(正確には、非同期処理を行うことができますが、configure({ enforceActions: "always" })を設定している場合に、非同期処理の中でStoreの変数を変更してしまうとエラーが発生してしまいます)。非同期処理は@actionをつけていないメソッドの方に切り出すか、runInActionを使うことで、エラーを回避することができます。

VuexとMobXの機能対応表

Vuex MobX
state @observable
getters @computed
mutation @action
action なし(メソッド)

ストア側の説明はだいたいこんなところです。

このストアをReactのコンポーネントで使うときは、以下のように使います。

.
├── App.css
├── App.tsx
├── components
│   └── Counter.tsx   // 追加
├── index.css
├── index.tsx
├── store
│   ├── CounterModule.ts
│   └── store.ts
Counter.tsx
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useStore } from "../store/store";

export const Counter = observer(() => {
  // context経由でストアを取得
  const { counterModule } = useStore();

  return (
    <div>
      <div>カウンター: {counterModule.count}</div>  // counterModule.count でcountを参照できる
      <div>カウンターを2倍にした数字: {counterModule.doubleCnt}</div>
      <p>
        <button onClick={() => counterModule.increment()}>カウントを増やす</button>   // commitやdispatchを呼び出す必要はなくメソッドを呼び出すだけ
      </p>
      <p>
        <button onClick={() => counterModule.decrement()}>カウントを減らす</button>
      </p>
    </div>
  );
});

このコンポーネントをApp.tsxに登録します。

App.tsx
import React from "react";
import "./App.css";
import { Counter } from "./components/Counter";
import { storeContext, store } from "./store/store";

const App: React.FC = () => {
  return (
    <storeContext.Provider value={store}>
      <div className="App">
        <Counter></Counter>    // 追加
      </div>
    </storeContext.Provider>
  );
};

export default App;

今回はFunctional Componentを使用していますが、Functional ComponentでMobXを利用する場合は、コンポーネント全体をobserver()関数でラップする必要があります。これを行わないとMobXでの変更がReactにうまく伝わりません。逆に言うと、MobXの変更を検知する対象のコンポーネントをより明示的に指定することができるので、パフォーマンス・チューニングの際などにはVuexよりも便利です。

コンポーネントの中では、useStore()を使ってRootStoreのオブジェクトを取得しています。ここで取得したcounterModuleの中にあるプロパティやメソッドを直接呼び出すことでストアを操作することができます。Vuexの場合は、呼び出したい処理によって、state getters commit dispatch を呼び分ける必要がありましたが、MobXの場合は、直接CounterModuleのインスタンスを取得でき、その変数やメソッドを直接呼び出すことができるのでより直感的に使うことができます。また、ただのプロパティの呼び出しなので何も考えなくても型が付きます。これはTypeScriptを導入する上で非常に大きなメリットになります。(余談ですが、MobX自体もTypeScriptで書かれており、そういう意味で非常に型フレンドリーなStoreライブラリになっています)

Moduleの中から他のModuleを参照できるようにする

さて、上記までで十分にVuex的なのですが、Vuexの代替として利用するには、このままではいくつか足りない機能があるので追加していきます。
まずは、Vuexのように、あるModuleの中から別のModuleを参照できるようにしましょう。

以下のような ModuleBaseというabstractクラスを作成します。

ModuleBase.ts
import { RootStore } from "./store";

/**
 * rootStore以外のStoreのベースになるクラスです
 * Storeクラスを作成する際に、これをextendsして使います
 */
export abstract class ModuleBase {
  /**
   * 各Storeの中で root storeにアクセスするためのメンバ
   * これを使うことで、VuexのrootGettersや rootActionのように、Store内で別のStoreにアクセスすることができます
   **/
  protected rootStore: RootStore;

  constructor(rootStore: RootStore) {
    this.rootStore = rootStore;
  }
}

これを CounterModuleクラス に継承させましょう。これによって、CounterModuleの中でthis.rootStoreを参照することで、別モジュールを含めたストア全体を参照することができるようになります。

CounterModule.ts
export class CounterModule extends ModuleBase {
   ..
   @action 
  /**
   * 他のcounterStoreのincrementを呼び出す
   * rootActionやrootMutation相当の機能を提供する
   */
  anotherIncrement() {
    // this.rootStoreをつかうことでstore全体を参照することができる
    // 例えばmasterデータやログインIDなど、アプリ全体で一意な値を他のStoreから取得する際に使用する
    this.rootStore.counterModule2.increment();
  }
}
store.ts
import { CounterModule } from "./CounterModule";
import { configure } from "mobx";
import React, { useContext } from "react";

configure({ enforceActions: "always" });

export class RootStore {
  counterModule = new CounterModule(this); // コンストラクタの引数にthis(rootStore)のインスタンスを渡す
}

export const store = new RootStore(); // Storeのインスタンスを作成
export const storeContext = React.createContext(store); // storeのcontextを作成
export const useStore = () => useContext(storeContext); // コンポーネントでstoreを簡単に読み込むためのカスタムフックを作成

mapState()、 mapActions()、mapModules()の実装

最後に、mapStatemapActionsの実装です。これは実装しなくてもぜんぜん使えるのですが、Vuexを使っていた人であれば、あると便利かなと思って実装しました。まず以下のようなStoreBaseクラスを作ります。

StoreBase.ts
import { action } from "mobx";
type ValueOf<T> = T[keyof T];

/**
 * すべてのストア(rootStoreを含む)のベースになるクラスです
 * rootStore以外では、これを継承したModuleBaseクラスが存在するので、そちらを継承して使用します
 */
export abstract class StoreBase {
  /**
   * このストアに定義されたメソッドの一覧を返します。
   * ここで取得したメソッドのthisはStoreのクラスにバインドされるので、@action.boundをつけていないメソッドに関しても
   * コンポーネントの中で、関数にラップすることなく呼び出せるようになります。
   * VuexのmapActionsとmapMutationsをあわせたような機能をもっています
   * 例えば以下のように使えます
   *
   * <pre><code>
   *
   * const { increment } = store.counterStore.mapActions()
   *
   * return <button onClock={increment}></button>
   *
   * </code></pre>
   *
   * mapActionsを使わずに展開したメソッドは、@action.boundをつけていない場合
   * thisの参照が壊れてしまうので、うまく動かない場合があります。
   * 例えば以下のようなコードはうまく動きません
   *
   * <pre><code>
   *
   * const { increment } = store.counterStore
   *
   * return <button onClock={increment}></button>
   *
   * </code></pre>
   *
   * @return object メソッドのみを抽出したMap
   *
   */
  mapActions() {
    let res = {};
    Object.getOwnPropertyNames((this as any)["__proto__"]).forEach(key => {
      if (typeof (this as any)[key] === "function") {
        (res as any)[key] = (...args: any) => (this as any)[key](...args);
      }
    });
    return res as Omit<
      this,
      | "mapActions"
      | "mapState"
      | "mapModules"
      | ValueOf<{ [K in keyof this]: this[K] extends Function ? never : K }>
    >;
  }

  /**
   * このストアに定義された変数とgetterの一覧を返します。VuexのmapStateとmapGettersを合わせたような機能を持っています。
   * 以下のように使えます
   *
   * <pre><code>
   *
   * const { count } = store.counterStore.mapState()
   *
   * return <div>{count}</div>
   *
   * </code></pre>
   *
   * ただし、プリミティブな値を利用する場合には、mapStateを呼び出すコンポーネント全体をmobx-react#observer()関数でラップしてください
   * ラップしないと変更が検知されません
   *
   * また、返り値をTypeScriptの型で縛っているだけなので、実際にはthisを返すだけです
   *
   * @return object メソッド以外の変数を抽出したMap
   *
   */
  mapState() {
    return this as Omit<
      this,
      ValueOf<
        { [K in keyof this]: this[K] extends Function ? K : this[K] extends StoreBase ? K : never }
      >
    >;
  }

  /**
   * このストアの下に定義されたModuleStoreの一覧を返します。
   * 以下のように使えます
   *
   * <pre><code>
   *
   * const { childStore } = store.counterStore.mapModules()
   *
   * return <div>{count}</div>
   *
   * </code></pre>
   *
   * また、返り値をTypeScriptの型で縛っているだけなので、実際にはthisを返すだけです
   *
   * @return object Storeのみ抽出したMap
   *
   */
  mapModules() {
    return this as Omit<
      this,
      ValueOf<{ [K in keyof this]: this[K] extends StoreBase ? never : K }>
    >;
  }
}

このStoreBaseクラスをModuleBaseクラスに継承します。

StoreBase.ts
import { RootStore } from "./store";
import { StoreBase } from "./StoreBase";

/**
 * rootStore以外のStoreのベースになるクラスです
 * Storeクラスを作成する際に、これをextendsして使います
 */
export abstract class ModuleBase extends StoreBase { // StoreBaseクラスを継承する
  /**
   * 各Storeの中で root storeにアクセスするためのメンバ
   * これを使うことで、VuexのrootGettersや rootActionのように、Store内で別のStoreにアクセスすることができます
   **/
  protected rootStore: RootStore;

  constructor(rootStore: RootStore) {
    super();
    this.rootStore = rootStore;
  }
}

これによって、StoreBaseクラスやModuleBaseクラスを継承したModuleでは、コンポーネント側でmapState() mapActions() mapModules()の3つのメソッドを利用できるようになります。

使い方

コンポーネント側での使い方は以下のような感じです。

Counter.tsx
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useStore } from "../store/store";

export const Counter = observer(() => {
  // context経由でストアを取得
  const { counterModule } = useStore();

  // mapState()を使うことで、このストアにある変数とgetterの一覧を取得することができる
  const { count, doubleCnt } = counterModule.mapState();

  // mapActions()を使うことで、このストアにあるメソッドの一覧を取得することができる
  const { increment, decrement } = counterModule.mapActions();

  // mapModules()を使うことで、このストアの下に登録されているStoreの一覧を取得できる
  const { childModule } = countermodule.mapModules();

  return (
    <div>
      <div>カウンター: {count}</div>
      <div>カウンターを2倍にした数字: {doubleCnt}</div>
      <p>
        <button onClick={increment}>カウントを増やす</button>
      </p>
      <p>
        <button onClick={decrement}>カウントを減らす</button>
      </p>
    </div>
  );
});

実装の解説

簡単に実装の解説をしておきます。

mapStateについて

mapState()はVuexの mapStatemapGettersをあわせたような機能を提供します。
mapState() は実際にはthisを返すだけです。なのでこれは、mapState()を呼び出さずにconst { count } = counterModuleと書いた時と同等です。ただし、TypeScriptの型で、メソッドとStoreBase型のプロパティを対象から外しているので、メソッドや子モジュールを参照しようとするとTypeErrorになるようになっています。

mapModulesについて

これもmapState()と同様です

mapActinsについて

mapState()はVuexの mapActionsmapMutationsをあわせたような機能を提供します。
これだけ唯一まともなJSの実装が入っています。@actionで定義するメソッドはプロトタイプメソッドなのでconst { increment } = counterModule のように直接展開してしまうとthisの参照が壊れてしまい、うまく動作しません。これを解決するために、mapActions()では、クラスに定義されたメソッドをアロー関数でラップしたものを返すようにしています。これによってメソッドを展開してもthisの参照が壊れないようにしています。
また、mapStateなどと同じように、メソッド以外のプロパティへのアクセスをTypeScriptの型で禁止しています。

おわりに

以上、MobXでVuexっぽいストアを作成する方法について解説しました。
実は、今回紹介方法は公式サイトのベストプラクティスにVuexユーザー向けに多少のアレンジを加えたものになっています。
https://mobx.js.org/best/store.html
Vuexの公式サイトでも、「MobXとVuexはめっちゃにてるぜ!」って言ってるぐらいなので、大体のMobXプロジェクトは今回紹介したような構成になってるんじゃないかと思います。

わざわざReactでVuexっぽいストアを使うぐらいならVueでいいんじゃない?という意見もあるかもしれませんが、
Vuexとの最大の違いは、TypeScriptとの相性が非常によく、ほとんど何もしなくても型が自動的についてくれるというところにあると思っています。
React自体もVueよりは型フレンドリーなので1、React + TypeScriptで型安全に開発したいんだけれど、Redux難しそう・・・とか思っている人は、ぜひMobXを使ってみてください。Vuexになれたユーザーであればとても使いやすいと思います。


  1. Vue3.0はTypeScriptで実装されるようなので、3.0が出ると状況は変わるかもしれません 

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4