はじめに
状態管理ライブラリを選ぶとき、「どの状態を購読するか」を毎回自分で書くのが地味に面倒だと感じたことはないでしょうか。
Reduxのセレクター、ZustandのuseStore((state) => state.count)のような書き方は、パフォーマンスのために必要な作法とはいえ、書き忘れると無駄な再レンダリングが起きます。MobXはこの「購読の宣言」を自動化するという、少し変わったアプローチを取るライブラリです。
observableにした値を読んだだけで、MobXが「このコンポーネントはこの値に依存している」と自動で覚えてくれます。書き手が意識するのは「どの値が変わりうるか(observable)」と「どこで変更するか(action)」だけ。この透過的な依存関係トラッキングこそが、MobXの一番の個性です。
とはいえ、説明より動かした方が早いと思います。makeAutoObservableでクラスをobservable化し、observerでReactコンポーネントを包むところまでを動かせるサンプルを用意しました。先に挙動を見たい方はこちらからどうぞ。
MobXとは
MobXは、シンプルでスケーラブルな状態管理を目指すJavaScript向けのライブラリです。「Transparent functional reactive programming(TFRP)」を謳っており、observableな状態を読み取った箇所を自動追跡し、その値が変化したときだけ関連する処理を再実行します。
Michel Weststrateによって2015年に公開され、現在の最新バージョンはv7.0.0です。MITライセンスで公開されており、React・Vue・Angular・Svelteなど、特定のUIフレームワークに縛られず利用できるのも特徴です。React向けにはmobx-react-liteという公式バインディングが用意されています。
主な特徴
- 透過的な依存関係トラッキング -
observerでラップしたコンポーネント内で読んだobservableプロパティを自動で購読する。セレクターを手書きする必要がない - ミニマルなAPI - 覚える概念は
observable・action・computed・reactionの4つが中心 - computedによる派生値のキャッシュ - 依存する値が変わるまで再計算されない、メモ化された算出プロパティを簡単に作れる
- クラスベース・オブジェクトベースの両対応 -
makeAutoObservableはクラスにもプレーンオブジェクトにも使える - フレームワーク非依存 - React以外にVue、Angular、Vanilla JSでも動作する
インストール
npm install mobx
Reactで使う場合は、Hooksベースの軽量バインディングを追加します。
npm install mobx-react-lite
クラスコンポーネントも扱いたい場合は、代わりにmobx-reactをインストールしてください。
MobXのサンプルを動かす
以下はmakeAutoObservableでクラスをobservable化し、mobx-react-liteのobserverでReactコンポーネントを包んで自動再レンダリングを実現するサンプルです。ステップ数を入力欄で変えてから「+」「-」を押すと、増減する量がその場で変わります。
要点だけを抜き出すとこうなります。コンストラクタでmakeAutoObservable(this)を呼ぶだけで、クラスの全プロパティがobservableに、メソッドがactionになります。observerで包まれたコンポーネントは、レンダー中に読んだプロパティ(ここではcountとstep)だけを自動で購読します。
import { makeAutoObservable } from 'mobx'
import { observer } from 'mobx-react-lite'
class CounterStore {
count = 0
step = 1
constructor() {
makeAutoObservable(this)
}
increment = () => { this.count += this.step }
decrement = () => { this.count -= this.step }
setStep = (step) => { this.step = step }
}
const counterStore = new CounterStore()
// observerが、レンダー中に読んだobservableだけを自動で購読する
const Counter = observer(() => (
<button onClick={counterStore.increment}>{counterStore.count}</button>
))
実際に動かせるものが下です。入力欄でステップ数を変えてから「+」「-」を押すと、増減する量がその場で変わります。
ポイントは、counterStore.incrementのようにストアのメソッドを直接onClickへ渡しているだけで、ZustandのようなuseStore()フックの呼び出しが要らない点です。observerがレンダー時にcounterStore.countとcounterStore.stepを読んだことを記録し、どちらかが変化したときだけこのコンポーネントを再レンダリングします。
基本的な使い方
MobXはReact専用ではありません。まずはReactを使わない、最小構成の例で仕組みを確認してみます。
import { makeAutoObservable, autorun } from 'mobx'
class Todo {
title
done = false
constructor(title) {
this.title = title
makeAutoObservable(this)
}
toggle() {
this.done = !this.done
}
}
const todo = new Todo('MobXを学ぶ')
// autorunはobservableの読み取りを自動追跡し、値が変わるたびに再実行する
autorun(() => {
console.log(`${todo.title}: ${todo.done ? '完了' : '未完了'}`)
})
todo.toggle() // ログが自動的に再出力される
autorunに渡した関数は、実行中に読んだobservableプロパティ(ここではtodo.titleとtodo.done)を自動で記録します。以降、それらのプロパティが変化するたびに関数が再実行されるため、todo.toggle()を呼ぶだけでログが再出力されます。この「読んだものを自動追跡する」仕組みが、observerによるReactコンポーネントの自動再レンダリングの正体でもあります。
実践的なユースケース
computedでTodoリストの進捗を出す
一覧から件数や割合を出す処理を素朴に書くと、レンダーのたびに毎回計算し直すことになりがちです。MobXのcomputed(クラスのgetterとして書くと自動的にcomputedになる)は、依存する値が変わらない限り計算結果をキャッシュしてくれるため、無駄な再計算を避けられます。
class TodoStore {
todos = []
constructor() {
makeAutoObservable(this)
}
addTodo(title) {
this.todos.push({ title, done: false })
}
// getterとして書くとcomputedになる。依存するtodosが変わるまで再計算されない
get completedCount() {
return this.todos.filter((t) => t.done).length
}
get progress() {
if (this.todos.length === 0) return 0
return Math.round((this.completedCount / this.todos.length) * 100)
}
}
下のサンプルでは、チェックボックスの切り替えやTodoの追加に応じてprogress(進捗率)とcompletedCount(完了数)がその場で更新されます。
チェックボックスを何度切り替えても、progressはcompletedCountとtodos.lengthが変化した瞬間だけ再計算されます。関係のないobservable(例えば別のストアの値)が変化しても、このgetterは再計算されません。これがcomputedのキャッシュの効果です。
runInActionで非同期処理を安全に更新する
MobXは既定で「observableの変更はaction内で行う」ことを前提にしています。async関数はawaitのあとに実行が再開される時点で、元のaction呼び出しのコンテキストから外れてしまうため、そのままだと変更が警告の対象になることがあります。runInActionは、その続きの部分だけを新しいactionとして囲むためのヘルパーです。
class UserStore {
users = []
isLoading = false
error = null
constructor() {
makeAutoObservable(this)
}
async fetchUsers() {
this.isLoading = true
try {
const users = await fakeApiCall()
// awaitの後はaction外になるため、runInActionで再度actionとして囲む
runInAction(() => {
this.users = users
this.isLoading = false
})
} catch (e) {
runInAction(() => {
this.error = e.message
this.isLoading = false
})
}
}
}
下のサンプルでは、外部通信の代わりにPromiseで擬似的なユーザー取得を再現しています。「取得」ボタンで成功パターン、「失敗させて取得」ボタンでcatch節を通るパターンを試せます。
「取得」を押すとisLoadingが一瞬trueになり、ユーザー一覧が表示されます。「失敗させて取得」を押すとerrorに文字列がセットされ、赤字のエラーメッセージが出ます。runInActionで囲んでいるのはawaitのあとの代入部分だけで、isLoading = trueの初期設定は通常のメソッド呼び出し(すでにactionの中)なのでそのまま代入できる、という使い分けがポイントです。
useLocalObservableでコンポーネント専用のストアを作る
ここまではモジュールスコープのグローバルなストアを使ってきましたが、フォームの入力状態のようにコンポーネント単位で閉じていてよい状態には、mobx-react-liteが提供するuseLocalObservableフックが向いています。呼び出すたびに独立したobservableオブジェクトを作ってくれます。
import { useLocalObservable, observer } from 'mobx-react-lite'
const SignupForm = observer(() => {
const form = useLocalObservable(() => ({
email: '',
password: '',
get isValid() {
return form.email.includes('@') && form.password.length >= 8
},
setEmail(v) { form.email = v },
setPassword(v) { form.password = v },
}))
return (
<input value={form.email} onChange={(e) => form.setEmail(e.target.value)} />
)
})
下のサンプルでは、メールアドレスとパスワードそれぞれにバリデーションのcomputed(isEmailValid・isPasswordValid)を持たせ、両方が揃うとボタンが押せるようになります。
emailを空にすると即座に「❌ @を含めてください」に切り替わり、両方の条件を満たすとボタンが有効になります。グローバルなストアと違い、このフォームの状態はコンポーネントがアンマウントされれば一緒に破棄されるので、モーダルの入力欄のような一時的な状態管理にも安心して使えます。
まとめ
MobXの一番の特徴は、observableにした値を読んだだけで依存関係が自動的に記録される、透過的なリアクティビティにあります。
makeAutoObservableでクラス(またはオブジェクト)をobservable化すれば、プロパティの読み書きがそのまま状態管理になるobserverでReactコンポーネントを包めば、セレクターを書かなくても必要な再レンダリングだけが起きるcomputed(getter)は依存する値が変わるまで再計算されないため、派生値の計算を安心して書ける- 非同期処理では、
awaitのあとの代入をrunInActionで囲む - コンポーネントに閉じた状態には
useLocalObservableが便利
「どの値を購読するか」を書かなくていいという体験は、一度慣れると手放しにくいものがあります。Reduxやselector形式のライブラリで「購読の書き忘れ」に悩んだことがある方は、一度小さなストアから試してみてください。