MobX と React Hooks をうまく組み合わせ、プレーンなものと書き味の変わらない React コンポーネントを書くアイデアを紹介します。
(オリジナルのアイデアは私ではなく誰かのブログに書かれていましたが、そのリンクを失念してしまいました。見つけ次第、リンクを貼りたいと思います。)
見つけました https://blog.mselee.com/posts/2019/06/08/using-mobx-with-react-hooks-typescript/
MobX store を使うコンポーネントは特殊な書き方を強いられる
MobX の特徴 - observable な store クラス
MobX は Single Page Application における状態管理ライブラリーの一つで、よく React と組み合わせて使われます。
MobX が特徴的なのは、状態を observable な store クラスで表現する点です。具体的には次のように、@observable
デコレーター をつけたメンバーを持つクラスです:
import { observable, computed, action, flow } from 'mobx'
type Todo = {
userId: number
id: number
title: string
completed: boolean
}
/**
* TODO 一覧を管理する store
*/
export default class TodosStore {
/**
* TODO 一覧
*/
@observable todos?: Todo[]
/**
* TODO 一覧を読み込み中のとき true
*/
@computed get loading() {
return !this.todos
}
/**
* TODO の一つの完了/未完了を切り替える
*
* @param id 対象の TODO の ID
*/
@action.bound toggle(id: number) {
this.todos = this.todos?.map(todo => {
if (todo.id === id) {
todo.completed = !todo.completed
}
return todo
})
}
/**
* TODO 一覧を API から取得する
*/
fetch = flow(function*(this: TodosStore) {
this.todos = undefined
this.todos = yield fetch(
'https://jsonplaceholder.typicode.com/todos?userId=1',
).then(r => r.json())
}).bind(this)
}
React において、状態を表すオブジェクトは immutable であることが原則です。しかし MobX は、@observable
デコレーターにより、mutable な store インスタンスを状態を表すオブジェクトとして扱えるようにしています。
上記の TodosStore
において observable なのは todos
と loading
の値です。コードのイメージは次のようになります:
const store = new TodosStore()
store.todos // undefined
store.loading // true
// 非同期で TODO 一覧を取得する
await store.fetch()
// store インスタンスは同一のまま、メンバーの値だけが変化する。つまり store が mutable
store.todos // [{...}, {...}, ...]
store.loading // false
強いられる特殊な書き方 - observer コンポーネント
React が immutable な状態オブジェクトを要求するのは、状態オブジェクトに変更があればそれを検知し直ちにコンポーネントを再描画するためです。Mutable なオブジェクトを状態に使うとその機構がうまく働かず、状態が変化したのに再描画が起きない不具合となります。その点は MobX を使ったとしても同じで、次のように書くと、TODO を読み込んだあとも再描画が起きなくなります:
import React, { useEffect } from 'react'
import { Section, Title, Loading, Todo } from './components'
import TodosStore from './TodosStore'
const store = new TodosStore()
export default function App() {
// store の todos や loading の値が変化したことをコンポーネントは検知できない。
const { todos, loading, toggle, fetch: fetchTodos } = store
// そのため API から TODO 一覧を取得しても、
useEffect(() => {
fetchTodos()
}, [fetchTodos])
// ずっと読み込み中表示のまま。
if (loading) {
return (
<Section>
<Loading />
</Section>
)
}
// ここには至らない。
return (
<Section>
<Title>Todos</Title>
{todos?.map(({ id, title, completed }) => (
<Todo
key={id}
label={title}
completed={completed}
onChange={() => toggle(id)}
/>
))}
</Section>
)
}
これを回避するため、MobX store を使うコンポーネントは特殊な書き方を強いられます。つまり、observer
関数 でラップしてやる必要があります:
import React, { useEffect } from 'react'
+import { observer } from 'mobx-react'
import { Section, Title, Loading, Todo } from './components'
import TodosStore from './TodosStore'
const store = new TodosStore()
-export default function App() {
+export default observer(function App() {
const { todos, loading, toggle, fetch: fetchTodos } = store
useEffect(() => {
...
))}
</Section>
)
-}
+})
コンポーネントが MobX 専用となってしまい、再利用性やテストの観点でやりづらさを生じ得ます。できることなら JSX.Element を返すだけのただの関数 としてプレーンなコンポーネントを定義したいものです。
useObserver
hooks を使い observer
をなくす
mobx-react 公式で紹介されている方法 - JSX.Element を返す
observer
を使わない書き方は mobx-react のドキュメントで紹介されています。それは useObserver
hook を使った書き方で、次のようなものです:
import React, { useEffect } from 'react'
-import { observer } from 'mobx-react'
+import { useObserver } from 'mobx-react'
import { Section, Title, Loading, Todo } from './components'
import TodosStore from './TodosStore'
const store = new TodosStore()
-export default observer(function App() {
- const { todos, loading, toggle, fetch: fetchTodos } = store
+export default function App() {
+ const { fetch: fetchTodos } = store
useEffect(() => {
fetchTodos()
}, [fetchTodos])
+ return useObserver(() => {
+ const { todos, loading, toggle } = store
if (loading) {
return (
<Section>
<Loading />
</Section>
)
}
return (
<Section>
...
</Section>
)
-})
+ })
+}
たしかに observer
によるラップは消えました。しかし useObserver
の戻り値が JSX.Element で、違和感があります。何より、プレーンな関数であることが利点 のはずの hook が JSX.Element を返してしまうと、useObserver
やほかの hooks をまとめてカスタムフックを作ることが難しくなります。
改良した方法 - store の slice を返す
ドキュメントでは JSX.Element を返す方法が紹介されていたものの、useObserver
から JSX.Element を返す必要性はないようです。つまり store インスタンスを useObserver
でラップしてやれば、コンポーネントは store の mutable な変化を検知できるようです:
const store = new TodosStore()
export default function App() {
- const { fetch: fetchTodos } = store
+ // 複数の値を返すのにタプルを使っているが、単なる好み。オブジェクトを返すスタイルでもよい。
+ const [todos, loading, toggle, fetchTodos] = useObserver(() => [
+ store.todos,
+ store.loading,
+ store.toggle,
+ store.fetch,
+ ])
useEffect(() => {
fetchTodos()
}, [fetchTodos])
- return useObserver(() => {
- const { todos, loading, toggle } = store
-
if (loading) {
return (
<Section>
...
))}
</Section>
)
- })
}
useObserver
から、JSX.Element を返す代わりに、store の一部分 (slice) を返しています。JSX.Element を useObserver
で囲むことも、コンポーネント全体を observer
で囲むこともなくなり、プレーンなコンポーネントの書き方となりました。
この書き方をベースに useContext
を加え、より意味のあるカスタムフックを作ってみます。コンテキストを使うことにより App コンポーネントへ任意の store インスタンスを渡すことが可能になるほか、コンポーネントからは store を使っていることがほとんど意識されない書き味になります:
import React, { useEffect } from 'react'
-import { useObserver } from 'mobx-react'
import { Section, Title, Loading, Todo } from './components'
-import TodosStore from './TodosStore'
+import useTodosStore from './useTodosStore'
-
-const store = new TodosStore()
export default function App() {
- const [todos, loading, toggle, fetchTodos] = useObserver(() => [
+ // このファイルにべた書きされた store インスタンスではなく、コンテキスト経由の store を使うことで、
+ // テスト時や Storybook を使うときにモックしやすくなる。
+ const [todos, loading, toggle, fetchTodos] = useTodosStore(store => [
store.todos,
store.loading,
store.toggle,
ここで使われているカスタムフック useTodosStore
の実装は次のようになります:
import { createContext } from 'react'
import useStore, { Selector } from './useStore'
import TodosStore from './TodosStore'
const context = createContext<TodosStore | null>(null)
export const TodosProvider = context.Provider
// TodosStore の slice を取得するための hook
// 汎用の useStore を TodosStore 専用にした。
export default function useTodosStore<TSelection>(selector: Selector<TodosStore, TSelection>) {
return useStore(context, selector)
}
import { useContext } from 'react'
import { useObserver } from 'mobx-react'
export type Selector<TStore, TSelection> = (store: TStore) => TSelection
// useContext と useObserver を組み合わせた、任意の store 型に対応したカスタムフック。
// この hook を介して store slice を取得すれば、コンポーネントが store の mutable な変更を検知できる。
export default function useStore<TStore, TSelection>(
context: React.Context<TStore>,
selector: Selector<TStore, TSelection>,
) {
const store = useContext(context)
if (!store) {
throw new Error('need to pass a value to the context')
}
return useObserver(() => selector(store))
}
カスタムフックによって store の実体が隠蔽され、store がどこでインスタンス化されたかだけでなくそれが MobX store かどうかすら気にすることなく、store を使えるようになりました。
Store の種類を増やすたびに useXxxStore
のような hooks 名や定義ファイルが増えるのが気になる場合は、store の定義ファイルに hooks を書くなり、static メソッドとして hooks を(ついでに provider も)持たせるなりすればよいと思います。
まとめ
- MobX store を使うコンポーネントは特殊な書き方を強いられる
-
observer
によるラップ - MobX 専用の書き方はなくしたい
-
-
useObserver
hook を使いobserver
をなくせる-
useObserver
から store slice を返す - コンテキスト経由で store を受け渡すカスタムフックを作り、store の実体を隠蔽する
- カスタムフックの実装を変えれば、コンポーネントを修正することなく、MobX 以外の状態管理ライブラリーに切り替えることも可能
-