MobX の useObserver() でネストされたオブジェクトを扱う際の注意

[MobX と hooks でプレーンな書き味の React コンポーネントを書く](https://qiita.com/kazuma1989/items/16f68cf835031b03fb61) の続編です。こちらの記事で紹介した書き方に罠があったので、発生条件と回避方法を記載します。



# 罠 - オブジェクトの配列を操作するとき

## 前回のコード

簡単のため、`useContext` を適用する前のコードから出発します。これは問題なく動きます。

```tsx:App.tsx
import React, { useEffect } from 'react'
import { useObserver } from 'mobx-react'
import { Section, Title, Loading, Todo } from './components'
import TodosStore from './TodosStore'

const store = new TodosStore()

export default function App() {
  const [todos, loading, toggle, fetchTodos] = useObserver(() => [
    store.todos,
    store.loading,
    store.toggle,
    store.fetch,
  ])

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

```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()) as any
  }).bind(this)
}
```


## 罠にはまって動かないコード

次のように `this.todos` 内の `completed` を直接書き換えると、コンポーネントが変化を検知できず、画面にも反映されません。

```diff:TodosStore.ts
   /**
    * 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
-    })
+    const todo = this.todos?.find(t => t.id === id)
+    if (!todo) return
+
+    todo.completed = !todo.completed
   }
```

`App.tsx` では `store.todos` という配列自体を observe しており、配列内のオブジェクトまで observe しているわけではないためです：

```tsx:App.tsx
  const [todos, /* ... */] = useObserver(() => [
    store.todos,
    // ...
  ])
```

うまく動くときの書き方では `this.todos = this.todos?.map()` という代入によって配列ごと作り直しているため、変更が検知されています。コンポーネントが `completed` の変化を検知したわけではなく、`this.todos` の変化を検知して最新の値を取得したら、結果的に `completed` の値が変化していただけ、というわけです。


## 回避方法

### 1. 従来の `observer()` を使う

前回の記事で忌避しましたが、`observer()` を使ってコンポーネント定義を包んでやれば `completed` の変化も検知されます。

```diff:App.tsx
 import React, { useEffect } from 'react'
-import { useObserver } from 'mobx-react'
+import { observer } from 'mobx-react'
 import { Section, Title, Loading, Todo } from './components'
 ...
-export default function App() {
-  const [todos, loading, toggle, fetchTodos] = useObserver(() => [
+export default observer(function App() {
+  const [todos, loading, toggle, fetchTodos] = [
     store.todos,
     store.loading,
     store.toggle,
     store.fetch,
```

### 2. `useObserver()` で JSX 式を囲む

これも前回やめた書き方ですが、動き自体に問題はありません。コードは省略。

### 3.1. `useObserver()` 内で、それ以降必要なプロパティすべてを参照しておく

**参照** しさえすればよいので、次のように `forEach` で destruct するだけで動くようになります。

```diff:App.tsx
 export default function App() {
-  const [todos, loading, toggle, fetchTodos] = useObserver(() => [
+  const [todos, loading, toggle, fetchTodos] = useObserver(() => {
+    store.todos?.forEach(({ id, title, completed }) => {})
+
+    return [
       store.todos,
       store.loading,
       store.toggle,
       store.fetch,
+    ]
+  })
-  ])
```

`store.todos?.forEach(t => {})` だと動きません。`completed` を名指しで呼び出してやる必要があります。

意図不明なコードがあるように見えて気になるという場合は、次のように書いても大丈夫です：

```diff:App.tsx
 export default function App() {
   const [todos, loading, toggle, fetchTodos] = useObserver(() => [
-    store.todos,
+    store.todos?.map(({ id, title, completed }) => ({ id, title, completed })),
     store.loading,
     store.toggle,
     store.fetch,
   ])
```

横着して `store.todos?.map(t => ({ ...t }))` と書くこともできます。

### 3.2. `useObserver()` 内で `toJS()` しておく

「MobX の [toJS](https://mobx.js.org/refguide/tojson.html) はどうか？」と指摘があったので試しましたが、これも動作しました：

```diff:App.tsx
 export default function App() {
   const [todos, loading, toggle, fetchTodos] = useObserver(() => [
-    store.todos,
+    toJS(store.todos),
     store.loading,
     store.toggle,
     store.fetch,
   ])
```

この例では `Todo` 型が浅く単純なため `store.todos?.map(t => ({ ...t }))` でも大差ありませんが、`toJS()` のほうが汎用的でより良い選択肢でしょう。

ちなみに、横着して store まるごと `toJS()` するとうまくいきません。computed な値やメソッドが失われてしまうようです：

```tsx:App.tsx
  // NG
  const { todos, loading, toggle, fetch: fetchTodos } = useObserver(() =>
    toJS(store),
  )
```



# まとめ

- [MobX と hooks でプレーンな書き味の React コンポーネントを書く](https://qiita.com/kazuma1989/items/16f68cf835031b03fb61) のように `useObserver()` を使うと、画面がうまく更新されない罠にはまることがある
    - 配列内のオブジェクトのプロパティを直接書き換えると発生する
- 回避方法は 3 つ
    - 従来の `observer()` を使う ← **MobX と添い遂げるならおすすめ**
    - `useObserver()` で JSX 式を囲む
    - `useObserver()` 内で、それ以降必要なプロパティすべてを参照しておく
    - `useObserver()` 内で `toJS()` しておく ← **別の状態管理へ移行する過渡期におすすめ**