はじめに
Svelte で複雑な Store の一部分のデータを更新したときに、初期データが変わってしまう問題に遭遇しました。それを解決するために immer を使う方法を試したので、備忘録として残しておきます。
TL;DR
import { produce } from 'immer'
// 中略
update(
produce((draft) => {
draft.posts[postIndex].comment.push(comment)
})
)
環境
- svelte 3.38.1
- immer 9.0.2
モチベーション
以下のような複雑なストアがあるとします。
type User = { name: string posts: Post[] }
type Post = { title: string body: string comments: Comment[] }
type Comment = { body: string }
import { writable } from 'svelte/store'
const initialData: User = {
name: 'name1',
posts: []
}
const { subscribe, update } = writable(initialData)
export userStore = {
subscribe,
addComment(postIndex: number) {
// TODO: コメントを追加する処理
}
}
カスタムストアというテクニックを用いて、自由すぎる変更が行えないようにします(カスタムストアのドキュメント)。
さて、コメントを追加する処理
をどうやって書きましょうか。
// 中略
addComment(postIndex: number, comment: Comment) {
update(prevUser => {
prevUser.posts[postIndex].comment.push(comment)
return prevUser
})
console.log(initialData) // store を更新後に、初期データを表示してみる
}
log を見てみると、initialData が変わってしまっていることがわかります。
これはいわうゆる参照渡しのような挙動が起きているからです(「参照渡しが起きている」とは言っていません!)
(参考:https://qiita.com/yuta0801/items/f8690a6e129c594de5fb)
もし、initialData に reset する必要があるとしたら大変です。
これを防ぐには、破壊的変更を加えないで、新しいデータを return する必要があります。
addComment(postIndex: number, comment: Comment) {
update((prevUser) => ({
...prevUser,
posts: prevUser.posts.map((post) => ({
...post,
comments: [...post.comments, comment],
}))
}))
console.log(initialData); // 今度は、変更されない
}
今度は変更されませんが、...
の連続で書きづらいし読みづらいです。
immer を使う
immer はこうした複雑なデータを破壊的変更を加えずに、簡単に更新するためのライブラリです。
https://bundlephobia.com/result?p=immer@9.0.2
gzip 状態で 5kB ほどと軽めのライブラリです(Svelteのランタイムが軽すぎるせいで、これでも重く感じる人もいるかも)。
他の界隈を覗くと、React でも 「Redux で複雑なデータの reducer を書くのが辛いので immer を使う」ということが多々あるようです。
immer を使って先ほどのaddComment
を書いてみます。
addComment(postIndex: number, comment: Comment) {
update(prevUser => (
produce(prevUser, draft => {
draft.posts[postIndex].comment.push(comment)
})
))
console.log(initialData) // 変更されない
}
なんだかネストが深くて読みづらいです。
実は、以下のように、produce メソッドの第一引数を省略して、関数を直接渡すことで以下のようにスッキリ書くことができます。
(参考)
addComment(postIndex: number, comment: Comment) {
update(produce(draft => { // 関数を直接渡す
draft.posts[postIndex].comment.push(comment)
}))
console.log(initialData) // 変更されない
}
検証しきれていないこと
- deep clone をするという手もあるが、immer と比べてどちらがパフォーマンスが良いのか