9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

フロントエンドフレームワーク mobx-state-tree の紹介

Last updated at Posted at 2020-03-30

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の等価なモデルに変換してみましょう。

MobX
class Counter {
  @observable
  counter = 0;
  @computed
  public get doubleCounter() {
    return this.counter * 2;
  }
  @action.bound
  increment() {
    this.counter++;
  }
}

const c = new Counter();

↓↓↓↓

mobx-state-tree
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の例は、下記のように書けます。

mobx-state-tree
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をたたいて処理する場合、こんな感じに書くこともできます。

mobx-state-tree
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のバージョンアップで結構型まわりが失敗しはじめることがよくありました。
9
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
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?