34
21

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.

React #2Advent Calendar 2019

Day 21

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

Last updated at Posted at 2019-12-21

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

最終形のコード (Gist)

(オリジナルのアイデアは私ではなく誰かのブログに書かれていましたが、そのリンクを失念してしまいました。見つけ次第、リンクを貼りたいと思います。)
見つけました :point_right: 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 デコレーター をつけたメンバーを持つクラスです:

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

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

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 関数 でラップしてやる必要があります:

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 のドキュメントで紹介されています。それは useObserver hook を使った書き方で、次のようなものです:

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 な変化を検知できるようです:

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

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 の実装は次のようになります:

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)
}
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)

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?