React でネストしたオブジェクトの state 更新を楽にしたい
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ ネストが深いと直接書き換えできずにイミュータブルにするのは辛い
◆ Redux 関係探すと immer という便利そうなライブラリがあった
◆ Redux 関係探すと immer という便利そうなライブラリがあった
ネストするオブジェクトの state の更新
state が単純に input と対応みたいなのならともかく ネストしてる大きめのオブジェクトの場合って更新が面倒です例えばこういうのです
import { html, render, useState } from "https://unpkg.com/htm@3.0.4/preact/standalone.module.js"
const App = () => {
const [user_data, setUserData] = useState({
taro: {
likes: ["javascript", "php"],
},
jiro: {
likes: ["python", "ruby"]
},
})
const addLikes = (user, like) => {
setUserData({
...user_data,
[user]: {
likes: [...user_data[user].likes, like],
},
})
}
return html`
<div>
<button onclick=${() => addLikes("taro", "python")}>Add</button>
<pre>${JSON.stringify(user_data, null, " ")}</pre>
</div>
`
}
addLikes 関数で指定ユーザの likes 配列に要素を追加しています
直接更新しても変更なし扱いされるので イミュータブルになるよう新しいオブジェクトを作ることになります
... が使えることでかなり楽にはなりましたが それでもまだ面倒さと見づらさがあります
elm くらいに言語レベルでイミュータブルな分 更新しやすい構文があると便利ですが JavaScript はそこまで便利でもないんです
一応 setXXX に渡す参照さえ違えば更新できるので
const addLikes = (user, like) => {
user_data[user].likes.push(like)
setUserData({...user_data})
}
にしてしまっても動くには動きます
user_data の中のネストした値を書き換えて
外側の user_data の参照だけ新しくします
Redux のタイムマシン機能とか そういうのいらないなら別に全部をイミュータブルにこだわる必要ないですし
とは言っても外側の参照だけ変える裏技的な対処方法で気持ち悪さが残ります
immer
Redux がこの面倒くささの代表的なものだと思ってるので そのあたりで良い方法ないかなと探してました使ったことがなかった Redux Toolkit のコードで 普通に state を更新して新しい state を return しないものを見つけました
https://redux-toolkit.js.org/tutorials/intermediate-tutorial
reducers: {
addTodo(state, action) {
const { id, text } = action.payload
state.push({ id, text, completed: false })
},
toggleTodo(state, action) {
const todo = state.find(todo => todo.id === action.payload)
if (todo) {
todo.completed = !todo.completed
}
}
}
どうなってるんだろうと調べると immer というライブラリをデフォルトで使っていて 直接 state 更新ができるようです
immer では 直接プロパティを変更したり配列の要素の追加削除をしても元の値はそのままで 結果として変更された新しいオブジェクトを取得できるという一見スゴイものです
ディープコピーをしてるわけじゃないので 大きなオブジェクトの 1 箇所だけの変更で重くはならないみたいです
使い方は
const obj = {
foo: {
bar: 10,
baz: 20,
}
}
const new_obj = immer.produce(obj, draft => {
draft.foo.bar = 1
})
console.log(new_obj)
// { foo: { bar: 1, baz: 20 } }
console.log(obj)
// { foo: { bar: 10, baz: 20 } }
produce 関数の 1 つめの引数に変更したいオブジェクトを渡して 2 つめに変更を行う関数を渡します
この関数の引数で draft オブジェクトを受け取れます
この draft オブジェクトを 1 つめの引数に渡したオブジェクトだと思って操作します
draft オブジェクトの実体は Proxy で 元のオブジェクトやコピーしたオブジェクトではありません
Proxy を操作して変更された内容が内部的に保存されていて 関数の処理が終わるとその変更を元のオブジェクトに適用した新しいオブジェクトが作られます
変更されていない部分はそのまま参照を使い回します
const obj1 = { foo: {}, bar: { baz: 1 } }
const obj2 = immer.produce(obj1, d => {
d.bar.baz = 10
})
console.log(obj1.foo === obj2.foo)
// true
console.log(obj1.bar === obj2.bar)
// false
foo は変化がないので同じ参照で bar は中の baz が変わったので別の参照になっています
スゴイなーと思いつつも Proxy を使って頑張って高度なことをしてるのって思わぬバグとかに出会いやすいのでちょっと怖いのですよね
Redux が推奨するくらいなのでそんなかんたんにバグに出会わないとは思いますけど
immer に置き換えてみる
上の方の App1 を immer を使って置き換えてみます import { produce } from "https://unpkg.com/immer@7.0.9/dist/immer.esm.js"
/* 略 */
const addLikes = (user, like) => {
setUserData(
produce(user_data, draft => {
draft[user].likes.push(like)
})
)
}
/* 略 */
addLikes 以外は変更なしです
ただ注意するところがあって immer の esm モジュールは Node.js 用になっています
process.env.NODE_ENV にアクセスできる必要があります
普通にブラウザで使うと process が見つからなくてエラーになります
esm 以外のグローバルに immer オブジェクトが作られる umd などを使えば解決できますが ES Modules でロードしたいという場合は import の前にダミーの process.env オブジェクトを作っておく必要があります
最後にプレビューできる全体版を置いておきます
App1 が元ので App2 が immer 使った版です
<!DOCTYPE html>
<script>
// immer workaround
window.process = {
env: {
NODE_ENV: "production",
}
}
</script>
<script type="module">
import { html, render, useState } from "https://unpkg.com/htm@3.0.4/preact/standalone.module.js"
import { produce } from "https://unpkg.com/immer@7.0.9/dist/immer.esm.js"
const App1 = () => {
const [user_data, setUserData] = useState({
taro: {
likes: ["javascript", "php"],
},
jiro: {
likes: ["python", "ruby"]
},
})
const addLikes = (user, like) => {
setUserData({
...user_data,
[user]: {
likes: [...user_data[user].likes, like],
},
})
}
return html`
<div>
<button onclick=${() => addLikes("taro", "python")}>Add</button>
<pre>${JSON.stringify(user_data, null, " ")}</pre>
</div>
`
}
const App2 = () => {
const [user_data, setUserData] = useState({
taro: {
likes: ["javascript", "php"],
},
jiro: {
likes: ["python", "ruby"]
},
})
const addLikes = (user, like) => {
setUserData(
produce(user_data, draft => {
draft[user].likes.push(like)
})
)
}
return html`
<div>
<button onclick=${() => addLikes("taro", "python")}>Add</button>
<pre>${JSON.stringify(user_data, null, " ")}</pre>
</div>
`
}
render(html`<${App1} />`, document.getElementById("app1"))
render(html`<${App2} />`, document.getElementById("app2"))
</script>
<div id="app1"></div>
<div id="app2"></div>