LoginSignup
2
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-11

MobX と hooks でプレーンな書き味の React コンポーネントを書く の続編です。こちらの記事で紹介した書き方に罠があったので、発生条件と回避方法を記載します。

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

前回のコード

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

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>
  )
}
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 を直接書き換えると、コンポーネントが変化を検知できず、画面にも反映されません。

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 しているわけではないためです:

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

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

回避方法

1. 従来の observer() を使う

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

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 するだけで動くようになります。

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 を名指しで呼び出してやる必要があります。

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

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 はどうか?」と指摘があったので試しましたが、これも動作しました:

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 な値やメソッドが失われてしまうようです:

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

まとめ

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