MobX と hooks でプレーンな書き味の React コンポーネントを書く の続編です。こちらの記事で紹介した書き方に罠があったので、発生条件と回避方法を記載します。
罠 - オブジェクトの配列を操作するとき
前回のコード
簡単のため、useContext
を適用する前のコードから出発します。これは問題なく動きます。
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>
)
}
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
を直接書き換えると、コンポーネントが変化を検知できず、画面にも反映されません。
/**
* 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 しているわけではないためです:
const [todos, /* ... */] = useObserver(() => [
store.todos,
// ...
])
うまく動くときの書き方では this.todos = this.todos?.map()
という代入によって配列ごと作り直しているため、変更が検知されています。コンポーネントが completed
の変化を検知したわけではなく、this.todos
の変化を検知して最新の値を取得したら、結果的に completed
の値が変化していただけ、というわけです。
回避方法
1. 従来の observer()
を使う
前回の記事で忌避しましたが、observer()
を使ってコンポーネント定義を包んでやれば completed
の変化も検知されます。
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 するだけで動くようになります。
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
を名指しで呼び出してやる必要があります。
意図不明なコードがあるように見えて気になるという場合は、次のように書いても大丈夫です:
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 はどうか?」と指摘があったので試しましたが、これも動作しました:
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 な値やメソッドが失われてしまうようです:
// NG
const { todos, loading, toggle, fetch: fetchTodos } = useObserver(() =>
toJS(store),
)
まとめ
-
MobX と hooks でプレーンな書き味の React コンポーネントを書く のように
useObserver()
を使うと、画面がうまく更新されない罠にはまることがある- 配列内のオブジェクトのプロパティを直接書き換えると発生する
- 回避方法は 3 つ
- 従来の
observer()
を使う ← MobX と添い遂げるならおすすめ -
useObserver()
で JSX 式を囲む -
useObserver()
内で、それ以降必要なプロパティすべてを参照しておく -
useObserver()
内でtoJS()
しておく ← 別の状態管理へ移行する過渡期におすすめ
- 従来の