はじめに
この記事は 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をやってた人のために、
mapState
とmapActions
を用意しておく
順番にやっていきましょう。
基本設計
なにはともあれ、とりあえず動く最小設計のストアを作ります。
今回の題材は、Vuexの公式でも紹介されているよくあるカウンターアプリです
以下のように、store.ts
とCounterModule.ts
を作成します。
.
├── App.css
├── App.tsx
├── index.css
├── index.tsx
├── store
│ ├── CounterModule.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を簡単に読み込むためのカスタムフックを作成
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を取得することができるようになります。
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
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
に登録します。
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
クラスを作成します。
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
を参照することで、別モジュールを含めたストア全体を参照することができるようになります。
export class CounterModule extends ModuleBase {
..略
@action
/**
* 他のcounterStoreのincrementを呼び出す
* rootActionやrootMutation相当の機能を提供する
*/
anotherIncrement() {
// this.rootStoreをつかうことでstore全体を参照することができる
// 例えばmasterデータやログインIDなど、アプリ全体で一意な値を他のStoreから取得する際に使用する
this.rootStore.counterModule2.increment();
}
}
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()の実装
最後に、mapState
やmapActions
の実装です。これは実装しなくてもぜんぜん使えるのですが、Vuexを使っていた人であれば、あると便利かなと思って実装しました。まず以下のようなStoreBase
クラスを作ります。
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
クラスに継承します。
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つのメソッドを利用できるようになります。
使い方
コンポーネント側での使い方は以下のような感じです。
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の mapState
とmapGetters
をあわせたような機能を提供します。
mapState()
は実際にはthisを返すだけです。なのでこれは、mapState()
を呼び出さずにconst { count } = counterModule
と書いた時と同等です。ただし、TypeScriptの型で、メソッドとStoreBase型のプロパティを対象から外しているので、メソッドや子モジュールを参照しようとするとTypeErrorになるようになっています。
mapModulesについて
これもmapState()
と同様です
mapActinsについて
mapState()
はVuexの mapActions
とmapMutations
をあわせたような機能を提供します。
これだけ唯一まともな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になれたユーザーであればとても使いやすいと思います。
-
Vue3.0はTypeScriptで実装されるようなので、3.0が出ると状況は変わるかもしれません ↩