$stateに引き続き、https://svelte.dev/docs/svelte/$derived を読んで理解を試みます。
$derived
state に依存する state を作成するための Rune。
state の更新に連動して derived の値が更新される。
<script>
let count = $state(0);
// count に連動して count * 2 の結果を返す state
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>
{doubled}
</button>
<p>{count} doubled is {doubled}</p>
React などに親しんでいると以下のように書けると思うかもしれないが、これは動かない。
<script>
let count = $state(0);
// script ブロックは最初に一度実行されるだけで、
// count が更新されても再実行されるわけではない
let doubled = count * 2;
</script>
ちなみに $derived(count * 2) は以下のような JS コードに変換された。
let doubled = $.derived(() => $.get(count) * 2);
実際に起こることとしては、$derived, $derived.by から参照された state が、その derived state の依存先として登録される。
依存先 state が更新されるとその derived state は dirty になり、次回参照時に再計算される、ということらしい。
state を使いつつ依存先としないようにするには、untrack が使える。
$derived.by
複雑な derived state を作りたいときは、関数を引数にとれる $derived.by を使って構築する。
<script>
let numbers = $state([1, 2, 3]);
let total = $derived.by(() => {
let total = 0;
for (const n of numbers) {
total += n;
}
return total;
});
</script>
なお、$derived(count * 2) と $derived.by(() => count * 2)) は全く同じ意味。
$derived の expression で別の state を更新してはいけない
$derived 使用時の注意として、
The expression inside $derived(...) should be free of side-effects. Svelte will disallow state changes (e.g. count++) inside derived expressions.
つまり $derived に記述する expression (上記例では count * 2) は副作用を持つべきではない (この中で他の変数を更新したりしてはいけない)。
なぜなら、更新をしてしまうと状態が不安定になるため (参考: https://svelte.dev/e/state_unsafe_mutation)。
以下の例では、表示される even, odd が count の値に対して不整合な状態になる可能性がある。
<script>
let count = $state(0);
let even = $state(true);
let odd = $derived.by(() => {
// ここで even を更新
even = count % 2 === 0;
//
// この状態 (even, odd に不整合がある) で UI が更新されてしまうかもしれない!
//
// ここで odd を更新
return !even;
});
</script>
<button onclick={() => count++}>{count}</button>
<p>{count} is even: {even}</p>
<p>{count} is odd: {odd}</p>
こういう場合は以下のように両方 $derived にするとよい。
<script>
let even = $derived(count % 2 === 0);
let odd = $derived(!even);
</script>
なぜ1つ目の例で上手くいかないのか?(予想)
$derived.by の中で起こる state の更新は svelte コンパイル時にトラッキングされず、UI 更新同期の対象外になるため?
derived state は上書き可能
derived state が依存先の state の更新による更新以外にも、直接値を代入する事ができる (const でない限り)。
この性質を使って、楽観的 UI を実装することができる。
以下は、公式に乗っていたいいねボタンの例。
<script>
let { post, like } = $props();
let likes = $derived(post.likes);
async function onclick() {
// API のレスポンスを待たずに🧡を +1 しておく
likes += 1;
// ここで API を呼び出し、実際の🧡を +1 する
try {
await like();
// 成功すると post が更新され post.likes が実際に +1 される
} catch {
// 失敗したのでロールバック
likes -= 1;
}
}
</script>
<button {onclick}>🧡 {likes}</button>
derived state は deep reactive ではない
$state で作った state に含まれる Object や配列は Proxy オブジェクトで再帰的にラップされる。しかし、$derived はそうではない。
このコード例で、カラーピッカーの変更がそのまま上のパレットにも反映されている。
なぜこうなるかというと、上記の理由から selected は直接 items の要素 (つまり Proxy) を参照することになり、selected を変更することが、そのまま items を更新したことになるため。
<script>
let items = $state([ /*...*/ ]);
let index = $state(0);
// selected は items[index] のオブジェクトそのものを参照している
// (Proxy 化されていない)
let selected = $derived(items[index]);
</script>
derived state は分割代入できる
<script>
let { a, b, c } = $derived(stuff());
</script>
このように書くと、だいたい以下のような意味になる。
<script>
let _stuff = $derived(stuff());
let a = $derived(_stuff.a);
let b = $derived(_stuff.b);
let c = $derived(_stuff.c);
</script>
つまり、分割代入元のプロパティを依存先とする derived state が作られる。
derived state の値が変わらない場合の挙動
依存先 state を更新しても derived state が変わらない場合がある。
こういう場合、derived state で更新はストップし、それ以降に更新は伝播しない。
以下の例では、 large がcount に依存し、さらに button のラベルが large に依存している。
<script>
let count = $state(0);
let large = $derived(count > 10);
</script>
<button onclick={() => count++}>
{large}
</button>
この場合、count が 11 になるまで large の値は false のままであり、それまで button の更新は実行されない。
ここで重要なのはおそらく「ラベルの表示内容が変わらない」という話だけでなく、そもそも button 要素の更新処理自体が実行されない、ということだと思われる。