mobx-keystone をやっていきたいと思います。
- 公式サイト: https://mobx-keystone.js.org/
- GitHub: https://github.com/xaviergonz/mobx-keystone
- 紹介記事: https://medium.com/@xaviergonz/mobx-keystone-an-alternative-to-mobx-state-tree-without-some-of-its-pains-8140767a3aa1
MST に比べて何がいいのか?
- 基本的にMSTと同じようにツリー状のJSONと等価なデータでアプリ状態を表す
- ツリーにメタデータ(
{$modelType: string, $modelId: string}
)が付くようです。-
applySnapshot()
する時もこれらが要求されるのでそこは面倒。
-
- ツリーにメタデータ(
- MSTと違い、(ほぼ)ネイティブなTypeScriptのクラス構造を用いることができる
- デコレータベースで記述するが、別にそうじゃない方法も用意されている
-
new Model()
でモデルの生成を行えます。
- MSTと違い、 モデル間の循環依存を扱うことができる
- 個人的にはこれが一番重要な特徴だと思います。MSTだと循環依存でハマる。誰でもハマる。
- MSTに比べてパフォーマンスもいい そうです
基本的にMSTを踏まえて作られたらしい。MSTの苦痛が減っていると嬉しいですね。
さっそく始める
セットアップが簡単な CRA で始めます。
$ yarn create react-app mks --template typescript
$ cd mks
$ yarn add mobx mobx-react-lite mobx-keystone
tsconfig.json
に下記を追加しておきましょう。
// decoratorを使うため
"experimentalDecorators": true,
// ES5がtargetだと非同期モデルアクションの記述で怒られるため
"downlevelIteration": true,
ものすごく簡単なカウンター
カウンターを例に簡単なモデルを書いてみましょう。
カウンターのモデル
カウンターのモデルのコードです。
extends Model({プロパティ定義})
するのと、副作用があるメソッドは @modelAction
で装飾することを除けば、だいたいMobXの流儀でいけます。(MSTからというより、MobXからの移行する人のほうがすんなり行けるかも??)
import { Model, prop, model, modelAction } from "mobx-keystone";
import React, { useContext } from "react";
import { computed } from "mobx";
@model("Counter")
export class Counter extends Model({
count: prop<number>(),
}) {
@computed
get doubled() {
return this.count * 2;
}
@modelAction
incr() {
this.count++;
}
@modelAction
decr() {
this.count--;
}
}
// モデルはコンテキストとして渡しましょう
export const CounterContext = React.createContext<Counter>(
new Counter({ count: 0 })
);
// モデルを利用するコンポーネント用hook
export const useCounter = () => useContext(CounterContext);
コンテナコンポーネント
カウンターモデルを表示するReactコンポーネントを書きましょう。MobXをReactコンポーネントと連動させるために、 mobx-react-lite
を使います。
注意点としては、MSTと異なり、メソッドが本当にクラスのメソッドなので、アクションメソッドの中でthisがバインドされていない点です。 onCick={counter.incr}
のように記述できません。 useCallback
を使うなり、 () => counter.incr()
とラムダで包むなりしないといけません。
export const App: React.FC<{}> = () => {
const counter = useCounter();
const incr = useCallback(() => {
counter.incr();
}, [counter]);
const decr = useCallback(() => {
counter.decr();
}, [counter]);
return useObserver(() => (
<div>
<h1>
{counter.count} を倍にすると {counter.doubled}
</h1>
<button onClick={incr}>+</button>
<button onClick={decr}>-</button>
{/* モデルをJSONとしても表示してみる */}
<pre>{JSON.stringify(getSnapshot(counter))}</pre>
</div>
));
};
動作させてみる
ちゃんと動きます。
非同期
こういったモデルプログラミングの上で、非同期処理の扱いは大事ですよね。MSTでは非同期処理の扱いは非常に簡単でした。
mobx-keystoneでも非常に簡単なようです。試しに指定ミリ秒数、遅延するインクリメントボタンを実装してみましょう。
@modelFlow
delayedIncr = _async(function* (this: Counter, delayedMs: number) {
// yield (単一値のジェネレート) ではなく yield* (複数値のジェネレート) を用いることに注意
yield* _await(sleep(delayedMs));
this.incr();
});
// 上記内に記述されている sleep は以下のような関数です
const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
@modelFlow
で修飾したプロパティについて _async(ジェネレータ関数)
を渡して、ジェネレータ関数内で yield* _await(...)
すれば非同期処理できるようです。
スナップショット
JSONデータとモデルを相互変換できないとキツいですよね。素のMobXの主な不満点はここらへんにあります。
基本的には getSnapshot()
も applySnapshot()
も存在してます。
const modelJson = getSnapshot(model)
applySnapshot(model, modelJson)
// インスタンスすら作れる
const newModel = fromSnapshot(modelJson)
が、applySnapshot
に自作のJSONをMSTの感覚で渡せるかというと、それは違うんですね。メタデータが必要になってきます。
applySnapshot(model, {
modelPropA: xxx,
$modelType: "Model",
$modelId: "何が入るかわからない…"
})
メタデータがある点が、MSTに比べてやや面倒です。
とりあえず今回はここまで
スナップショットの型が不穏という以外は、MSTよりよさそうな感じはします。もっとユーザーが増えるといいなあと思います。
実用的に使うとしたら、下記について網羅する必要があるかなと思います。
- ツリートラバーサル
- 祖先ノードの参照、子孫ノードの参照
- HMR
- 相互参照
- DI
- 配列・マップ構造
- さらなる型
- カスタム型
- オブジェクトのID