Qiita に mobx-state-tree の記事が全然なくて寂しいので紹介記事を書こうと思います。
- この記事ではReactと組み合わせて利用しています。
- 最近のReactがわからないと読むのがつらいと思います。
- サンプルコードの動作確認はしていません。雰囲気を掴むためだけのサンプルコードです。
- 書いてたけど、情熱が尽きてしまった…
MobX の紹介
mobx-state-tree を紹介する前に、 MobX を簡単に紹介したいと思います。
MobXはすごーーーく簡単に言えば、モデルの値の変更に自動連動するReactコンポーネントを作ることができるフレームワークです。
MobXでカウンタを作ってみる
たとえば、状態の定義として MobX で下記のようなクラス(ストア)を定義しておき、
class Counter {
// トラッキング可能な値の宣言
@observable
counter = 0;
// トラッキング可能な値から算出される値の宣言
@computed
public get doubleCounter() {
return this.counter * 2;
}
// トラッキング可能な値を変更するメソッドの宣言
// 基本的に @action (@action.bound) 内でのみ値は変更可能
@action.bound
increment() {
this.counter++;
}
}
上記のストアを React (+mobx-react-lite) で下記のように使うことにより、カウンタを作ることができます。
const c = new Counter();
const CounterReact: React.FC<{}> = () => {
// useObserver(...) 内でトラッキング可能な値の変化があった場合、コンポーネントを自動で再レンダリングしてくれる
return useObserver(() => (
<div>
<p>現在のカウント: {c.counter}</p>
<p>倍のカウント: {c.doubleCounter}</p>
<p><button onClick={c.increment}>カウントを増やす</button></p>
</div>
))
}
上記のようにオブジェクト指向風味にコードを書くだけで、クラスのプロパティなどの @observable
/ @computed
で装飾された値/計算式の変化に勝手に追従するReactコンポーネントを作ることができます。
直感的にアプリのモデルを書けるのがいいですね。
ログインフォームを作ってみる
もうちょっと複雑な例を見ていきましょう。
class LoginForm {
@observable
loginId = "";
@observable
password = "";
@observable
message = "";
@computed
get submitDisabled() {
// ログインIDもパスワードも両方入れないとログインさせないようにする
return this.loginId.length === 0 || this.password.length === 0;
}
@action.bound
setLoginId(value: string) {
this.loginId = value
}
@action.bound
setPassword(value: string) {
this.password = value
}
@action.bound
submitLogin() {
loginApi
.sendLogin({ loginId: this.loginId, password: this.password })
.then(action(() => {
// コールバック内は @action.bound が効いてないので、actionを呼ぶ
this.message = "ログインしました";
}))
}
}
const f = new LoginForm();
const FormReact: React.FC<{}> = () => {
return useObserver(() => <div>
<p>ログインID: <input type="text" value={f.loginId} onChange={ev => f.setLoginId(ev.currentTarget.value)} /></p>
<p>パスワード: <input type="text" value={f.password} onChange={ev => f.setPassword(ev.currentTarget.value)} /></p>
<p><button disabled={f.submitDisabled} onClick={f.submitLogin}>ログイン</button></p>
<p>{f.message}</p>
</div>)
}
ログインIDとパスワードを両方いれたら送信ボタンが有効になる簡素なログインフォームです。
mobx-state-tree の紹介
mobx-state-tree は MobX の補助ライブラリです。
クラスでも、普通の変数でも、なんでもモデル(ストア)にできる自由すぎる MobX に対して、秩序あるモデル(ストア)作成方法を導入してくれます。
なぜ mobx-state-tree が必要か? MobX の問題点
MobX はそれはそれで素晴らしいのですが、いくつか問題点があります。
- モデル(ストア) の作りが自由すぎる。基本的にはクラスで作ることが多いが、クラスで表現できることが多彩すぎる。
- モデル(ストア)となっていたクラスのインスタンスは基本的に HMR (Hot Module Replacement) できない。
- なにかファイルを更新した時にアプリの状態を引き継いだままHMRしてほしい。古いストアの値を新しいストアにコピーしたいけど、素直にはできない。
- 結局はJSONになるReduxはこの辺は難なくできる。
- Reduxみたいにアプリの状態をまるごと見たい。SSoT (Single Source of Truth)したい。
- デコレータがウザい。JavaScriptにはないデコレータ機能を使いたくない。
- 非同期処理の記述が難しい。
mobx-state-tree は、JavaScriptのクラスをストアとして使うのではなく、 mobx-state-tree が提供するライブラリでストアを構築させることにより上記を解決してくれます。
mobx-state-tree でカウンタを作ってみる
MobXで実装されたカウンタモデルの例を、mobx-state-treeの等価なモデルに変換してみましょう。
class Counter {
@observable
counter = 0;
@computed
public get doubleCounter() {
return this.counter * 2;
}
@action.bound
increment() {
this.counter++;
}
}
const c = new Counter();
↓↓↓↓
const Counter = types.model("Counter", {
// 初期値0の数値プロパティの宣言
counter: types.optional(types.number, 0)
})
.views(self => ({
get doubleCounter() { return self.counter * 2 }
}))
.actions(self => ({
increment() { self.counter++ },
}))
const c = Counter.create();
(動的にモデルを定義するコードが出てきて不安になったと思いますが、上記はきちんと型安全に定義されます。TypeScriptの型推論ってすごいね!)
モデルを使うコードは特にMobXでもmobx-state-treeでも特に変わりありません。
const CounterReact: React.FC<{}> = () => {
return useObserver(() => (
<div>
<p>現在のカウント: {c.counter}</p>
<p>倍のカウント: {c.doubleCounter}</p>
<p><button onClick={c.increment}>増やす</button></p>
</div>
))
}
mobx-state-tree ならではのJSONとの相互変換機能を試してみる
mobx-state-treeの何が嬉しいかわからないので、mobx-state-treeならではの操作を紹介します。
c.increment();
c.counter // => 1
// 今のカウンタの状態をJSONとして抜き出す (getSnapshot)
const counterJson = getSnapshot(c); // → { counter: 1 }
c.increment();
c.increment();
c.counter // => 3
// カウンタの状態をJSONを与えて戻す (applySnapshot)
applySnapshot(c, counterJson);
c.counter // => 1
上記は、モデルの状態をJSONにしたり、逆にJSONからモデルに状態を反映させたりしています。
- これはたとえば、HMRする時に、HMR前のアプリの状態を
getSnapshot()
で退避させておき、HMR後のアプリにapplySnapshot()
すれば、アプリ状態をHMR前後で引き継ぐことが可能ということを表しています。 - また、
getSnapshot()
で取得したJSONを参照することにより、アプリの状態を一発で把握することもできます。
mobx-state-tree でログインフォームを作成してみる
前述したMobXの例は、下記のように書けます。
const LoginForm = types.model("LoginForm", {
loginId: types.optional(types.string, ""),
password: types.optional(types.string, ""),
message: types.optional(types.string, "")
)
.views(self => ({
get submitDisabled() { return self.loginId.length === 0 || self.password.length === 0 }
}))
.actions(self => ({
setLoginId(value: string) { self.loginId = value },
setPassword(value: string) { self.password = value },
submitLogin: flow(function * () {
yield loginApi.sendLogin({ loginId: self.loginId, password: self.password });
self.message = "ログインしました";
})
}))
submitLogin
に mobx-state-tree の非同期処理の書き方が現れています。 flow
というmobx-state-treeが提供する関数に、awaitのかわりにyieldするジェネレータ関数を渡すことで、mobx-state-treeが非同期処理を面倒見てくれます。これは、結構楽です。
この手の非同期処理、たとえば、ページの初期ロード時にいろんなAPIをたたいて処理する場合、こんな感じに書くこともできます。
const ProfilePage = types.model("ProfilePage", {
// "init" | "loading" | "failed" | "loaded" のユニオン型で初期値が "init" という意味
state: types.optional(types.enumeration(["init", "loading", "failed", "loaded"]), "init"),
userEmail: types.optional(types.string, ""),
})
.actions(self => ({
init: flow(function * () {
self.state = "loading";
try {
const profileData: Profile = yield profileApi.getProfile();
self.userEmail = profileData.email
self.state = "loaded";
} catch (exception) {
self.state = "failed";
}
})
}))
const p = ProfilePage.create();
const ProfilePageReact: React.FC<{}> = () => useObserver(() => <div>
<h2>プロファイルページ</h2>
{ p.state === "loading" && <p>データロード中...</p> }
{ p.state === "failed" && <p>失敗! <button onClick={p.init}>再チャレンジ</button></p> }
{ p.state === "loaded" && <p>あなたのメルアド: {p.userEmail}</p> }
</div>)
最近はReactコンポーネントの中に非同期処理などを書く例を目にする機会が多いのですが、私はビジネスロジック的なものは、原則すべてモデルの中に入れて、Reactはモデルの描画だけに使うほうが好きですね。(老害)
リアルワールドな例
もっとリアルワールドな例を見てみましょう。(色々省いているけど…)
import { ModelA } from "./ModelA"
import { ModelB } from "./ModelB"
import { ModelC } from "./ModelC"
import { ModelD } from "./ModelD"
// ルートとなるモデル: 他全モデルをプロパティとして持つ
const App = types.model("App", {
a: ModelA,
b: ModelB,
c: ModelC,
d: ModelD
}).views(省略).actions(self => {
const init = () => {
// アプリの初期化処理
};
return {
init
}
})
// アプリのインスタンスを作成して、初期化処理を開始する
const app = App.create();
app.init();
// 普通、ストアはコンテキストを経由して渡す
const ModelContext = React.createContext(app)
const useModel = () => useContext(ModelContext);
const useModelA = () => useModel().a
// コンテナ等React要素の定義
const AppElement: React.FC<{}> = () => {
return <div>
<FeatureA />
<FeatureB />
<FeatureC />
<FeatureD />
</div>
}
cosnt FeatureA: React.FC<{}> = () => {
const a = useModelA();
return useObserver(() => <div>
{a.foobar}
</div>);
}
// アプリのレンダリング
ReactDOM.render(<AppElement />, document.getElementById("root"));
基本的には mobx-state-tree でこういった全モデルのルートとなるモデルを作成して、Reactに描画させるようにする感じになるかと思います。
ということで
SPAの状態管理が楽になるので、 mobx-state-tree 使う人がもっと増えないかと思っています。私は仕事でバリバリ使っています。(有名どころだと、DAZNが使っているらしいです。他社事例)
私ももともとはReduxを使っていたんですが、あのコード量に嫌気がさしてしまい(最近はそうでもないらしいですが)、MobXに流れ着き、そしてmobx-state-treeにたどり着いた経緯があります。
ちなみに、この記事では特に触れてないですが、
- きちんとReactコンポーネントは分割しましょう
- 特にコンテナとプレゼンテーショナルコンポーネントはわけましょう。
- 普通はあんな野放図にモデルを参照するコンポーネントは書かないでしょう。
- mobx-state-tree は、それなりに罠があります。
- 初期の頃は非常にMobXに比べて重かったりとかありました。
- TypeScriptのバージョンアップで結構型まわりが失敗しはじめることがよくありました。