MobX と hooks でプレーンな書き味の React コンポーネントを書く

MobX と React Hooks をうまく組み合わせ、プレーンなものと書き味の変わらない React コンポーネントを書くアイデアを紹介します。

[最終形のコード (Gist)](https://gist.github.com/kazuma1989/0714d3968b9f5a86f59896d153663605)

~~（オリジナルのアイデアは私ではなく誰かのブログに書かれていましたが、そのリンクを失念してしまいました。見つけ次第、リンクを貼りたいと思います。）~~
見つけました :point_right: https://blog.mselee.com/posts/2019/06/08/using-mobx-with-react-hooks-typescript/


# MobX store を使うコンポーネントは特殊な書き方を強いられる

## MobX の特徴 - observable な store クラス

[MobX](https://mobx.js.org) は Single Page Application における状態管理ライブラリーの一つで、よく [React](https://reactjs.org) と組み合わせて使われます。

MobX が特徴的なのは、状態を observable な store クラスで表現する点です。具体的には次のように、[`@observable` デコレーター](https://mobx.js.org/refguide/observable-decorator.html) をつけたメンバーを持つクラスです：

```ts:TodosStore.ts
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` の値です。コードのイメージは次のようになります：

```ts
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 を読み込んだあとも再描画が起きなくなります：

```tsx:App.tsx
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` 関数](https://mobx.js.org/refguide/observer-component.html) でラップしてやる必要があります：

```diff:App.tsx
 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](https://mobx-react.js.org) のドキュメントで紹介されています。それは [`useObserver` hook](https://mobx-react.js.org/observer-hook) を使った書き方で、次のようなものです：

```diff:App.tsx
 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 な変化を検知できるようです：

```diff:App.tsx
 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 を使っていることがほとんど意識されない書き味になります：

```diff:App.tsx
 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` の実装は次のようになります：

```ts:useTodosStore.ts
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)
}
```

```ts:useStore.ts
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 も）持たせるなりすればよいと思います。

# まとめ

[最終形のコード (Gist)](https://gist.github.com/kazuma1989/0714d3968b9f5a86f59896d153663605)

- MobX store を使うコンポーネントは特殊な書き方を強いられる
    - `observer` によるラップ
    - MobX 専用の書き方はなくしたい
- `useObserver` hook を使い `observer` をなくせる
    - `useObserver` から store slice を返す
    - コンテキスト経由で store を受け渡すカスタムフックを作り、store の実体を隠蔽する
        - カスタムフックの実装を変えれば、コンポーネントを修正することなく、MobX 以外の状態管理ライブラリーに切り替えることも可能