Svelte 4で作っていたSPAのコンポーネントをSvelte 5のRuneを使った書き方にマイグレーションしていて、個人的にハマった(苦戦した)ポイントが大きく分けて3つありました。
なお、SvelteKitは使っていない、ただのSvelteです。
- 再描画・再評価を引き起こすトリガーの違い
- ストアとRuneの違い
- ストアに持たせた参照の扱い
再描画・再評価を引き起こすトリガーの違い
実行時に参照されたものがトリガーとなる
Svelte 4では、テンプレート部分や $:
で書いたリアクティブな部分では、その中で現れた変数の値が変わると再描画・再評価されていました。
例えば、次のブロックでは isCounter1
、 counter1
、counter2
が現れます。これらの値のいずれかが変わるとこのブロックは再評価されていました。
// Svelte 4
let isCounter = true;
let counter1 = 0;
let counter2 = 0;
$: {
let value;
if (isCounter1) {
console.log("counter1");
value = counter1;
} else {
console.log("counter2");
value = counter2;
}
console.log(value);
}
Svelte 5になると、条件が少し変わって、前回の実行時に参照された変数の値が変わったときに再描画・再評価されるようになります。
先ほどのコードをSvelte 5のRuneを使って書き直してみます。
// Svelte 5
let isCounter = $state(true);
let counter1 = $state(0);
let counter2 = $state(0);
$effect(() => {
let value;
if (isCounter1) {
console.log("counter1");
value = counter1;
} else {
console.log("counter2");
value = counter2;
}
console.log(value);
})
この $effect
ブロックは、最初に実行されたときは isCounter1
を参照し、その値が true
なので次に counter1
を参照します。counter2
は参照せずに終わります。
すると、このブロックが再評価されるトリガーは isCounter1
と counter1
です。これらの値が変わったときに再評価が行われます。逆に、counter2
はトリガーではないので値が変わっても再評価されません。
ここで isCounter1
が false
に変わったとします。
isCounter1
はトリガーなので先ほどのブロックが再度実行されます。isCounter1
を参照し、今度は false
なので次に counter2
を参照します。すると、次に再評価を行うトリガーは isCounter1
と counter2
になります。
今度は counter1
が変わっても再評価されません。
そこから呼ばれた関数内で参照したものもトリガーとなる
ポイントはトリガーが実行されたときに決まるようになったことです。これが大きく影響してくるのが、次のようにブロック内で別の関数を呼び出した場合です。
以下はSvelte 4時代の公式ドキュメントに書かれていた例です。
Svelte 4ではリアクティブなブロックに直接登場する変数だけがトリガーとなるため、total
を計算する部分のトリガーは x
のみです。y
の値が変わっても total
は再計算されません。
// Svelte 4
let x = 0;
let y = 0;
function yPlusAValue(value) {
return value + y;
}
$: total = yPlusAValue(x);
これをSvelte 5のRuneを使って書き直してみます。
// Svelte 5
let x = $state(0);
let y = $state(0);
function yPlusAValue(value) {
return value + y;
}
let total = $derived(yPlusAValue(x));
total
を計算する $derived
が実行されると、まず x
が参照され、次に呼び出された yPlusAValue
内で y
が参照されます。Svelte 5では実行時にトリガーが収集されるため、次の再計算のトリガーは x
と y
の両方になります。
これは単純な例ですが、もっと複雑なコードになると、意識していないところでトリガーが増えていて、Svelte 4では行われなかった再評価が行われることがありました。
特に、$effect
を使って副次的な処理を行っている場合は、必要以上に処理が行われたり、場合によってはトリガーを自分で更新してしまい、無限に再評価が行われてしまう場合もあって注意が必要です。
特に、Rune(例えば $state
)で作られた値はコンポーネントを超えて参照できるので、呼び出す関数が別のファイルに定義された関数であったり、さらにそこから呼ばれた別の関数内でリアクティブな値を参照していたりすると意識していないところでトリガーが増えやすくなります。
トリガーに含めたくなければ untrack
を使う
untrack
に渡した関数の中で参照したものはトリガーとして収集されません。
例えば、以下のように yPlusAValue
の中で untrack
を使って y
を参照すると、yPlusAValue
を呼び出しても y
はトリガーとして収集されなくなり、Svelte 4のときと同じ動作をするようになります。
import { untrack } from "svelte";
...
function yPlusAValue(value) {
return value + untrack(() => y);
}
その関数の中では一切トリガーを収集させたくないのであれば、全体を untrack
で囲むという手もありそうです。
function yPlusAValue(value) {
return untrack(() => {
return value + y;
});
}
ストアとRuneの違い
Svelte 5では、拡張子 .svelte.js(TypeScriptなら .svelte.ts)のファイルにすることでSvelteコンポーネント以外のロジック部分においても、 $state
や $derived
などのRuneで作るリアクティブな値が使えるようになりました。
Svelte 4ではSvelteコンポーネントを跨がるロジック部分では writable
などで作ったストアを用意しておき、Svelteコンポーネントでそのストアの値に $
付きでアクセスしていました。
Svelte 5になってもストアは残っていますが、Runeで作ったリアクティブな値に置き換えられるものもあります。
ところが、ただ単純に置き換えようとしたらハマりました。
ストアはオブジェクト
ストアはオブジェクトです。同じものを他でも共通に参照することができます。
const counter = writable(0);
const another = counter;
another.set(2);
この場合、 counter
と another
の型はどちらも Writable<number>
であり、同じストアを参照しています。
一方で、$state
で作ったリアクティブな値はあくまでも値であってオブジェクトではありません。
let counter = $state(0);
let another = counter;
another = 2;
counter
も another
も型は number
です。
another
には counter
の現在値がコピーされただけで、another
がリアクティブな値になるわけではありません。
ストアの時のように、 counter
を他のロジック部分でも共有したいのであれば、クラスにくるんであげると良いです。
class Counter {
value = $state(0);
}
const counter = new Counter();
const another = counter;
another.value = 2;
クラスがやってくれること
クラスのインスタンスプロパティとしてリアクティブな値を持たせた場合、Svelteのコンパイラはそれをプライベートなフィールドと、その値にアクセスするゲッターとセッターに変換します。
上の Counter
クラスのコンパイル結果はこのようになります。
class Counter {
#value = $.state(0);
get value() {
return $.get(this.#value);
}
set value(value) {
$.set(this.#value, $.proxy(value));
}
Counter
インスタンスの value
を参照すると、実際にはゲッター関数が呼ばれてリアクティブな値の現在値が返されます。また、 そのタイミングでリアクティブな値を参照したということがわかります。
これにより、テンプレート部分や $effect
のブロックのようなリアクティブに再描画・再評価される部分から value
を参照したときに、それが更新のトリガーであるということが記録されるようになります。
余談ですが、リアクティブな値をトリガーとして収集したり、値が更新されたらそれをトリガーとする部分を再評価する処理は、Svelteコンパイラがリアクティブな値へのアクセスを上記のように $.get
と $.set
に書き換えることで実現されているようです。
クラスの場合、ゲッター・セッター内でそれが行われているので、クラスインスタンスを利用する側は書き換えの必要がありません。つまり、そこではSvelteコンパイラによる変換は不要です。
やや無理矢理な例ですが、次の Logic
クラスを定義するLogic.jsは拡張子が .svelte.js にはなっておらず、Svelteコンパイラはここには使われません。
それでも、Logic
の isEven
を呼び出した結果が、リアクティブに再描画されます。
(isEven
の呼び出しにより、NumberValue
の value
のゲッターが呼ばれるため、リアクティブな値のトリガーとして収集されるためです)
export class NumberValue {
value = $state(0);
}
import { NumberValue } from "./NumberValue.svelte";
export class Logic {
constructor() {
this.numberValue = new NumberValue();
}
update(value) {
this.numberValue.value = value;
}
isEven() {
return this.numberValue.value % 2 === 0;
}
}
<script>
import { Logic } from "./Logic";
const logic = new Logic();
function updateValue() {
logic.update(123);
}
</script>
{#if logic.isEven()}
偶数
{:else}
奇数
{/if}
<button onclick={updateValue}>更新</button>
このように、コンポーネントではないロジック部分のソースコードでは、$state
などのRuneを直接必要とする場合のみ、拡張子を .svelte.js にしてSvelteコンパイラを通せばいいようです。
クラスを使わない場合
キモは値の参照と値の更新を関数として提供することなので、クラスを使わなくても同様のことを自分で行うこともできます。
例えば、こんな感じで、ゲッター関数や更新関数を持たせたオブジェクトにすることもできます。この例ではクラスと変わらないのであまり意味がありませんが、クラスにしなければいけないわけではないということで…。
export function createCounter() {
let value = $state(0);
function increment() {
value = value + 1;
}
return {
get value() { return value; },
increment,
};
}
ストアに持たせた参照の扱い
Svelte 5は原則 "deeply reactive"
Svelte 5では原則として、リアクティブな変数にもともと参照していたのと同じオブジェクトが再代入されたとしても、値が変化したとは扱われない方針のようです。
ですが、 $state
に持たせたオブジェクトや配列は特に何もしなければ "deeply reactive" なので、基本的には問題ないはずです。
例えば以下の例では、App.svelte で updateUserName
と updateUser
のどちらを実行しても、postやuserを見ているPostコンポーネントでの再評価は発生せず、コンソールのログは増えません。
ですが、nameを見ているUserコンポーネントは updateUserName
によるnameの変化を検知して再描画されます。
<script>
import Post from "./Post.svelte";
let post = $state({
user: {
name: "Alice"
},
content: "This is a content."
});
function updateUserName() {
// userのプロパティを変更
post.user.name = post.user.name + "+";
}
function updateUser() {
// 同じものを代入
post.user = post.user;
}
</script>
<Post {post}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
<script>
import User from "./User.svelte";
let { post } = $props();
$effect(() => {
post; // postが変わったら再評価してもらうために参照しておく
console.log("post changed.");
});
$effect(() => {
post.user; // post.userが変わったら再評価してもらうために参照しておく
console.log("post.user changed.");
});
</script>
<User user={post.user}/>
<div>{post.content}</div>
<script>
let { user } = $props();
</script>
<div>{user.name}</div>
ストアは "deeply reactive" ではない
ストアは set
でこれまでと同じオブジェクトをセットしたり、 update
で同じオブジェクトを返すようにしても、値が変わったと判断されます(※数値や文字列はそうではありません)。ただし、ストアに入れた値はそのままでは "deeply reactive" にはありません。
先ほどの例のApp.svelteをストアを使うように書き直してみます。
<script>
import { writable } from "svelte/store";
import Post from "./Post.svelte";
const postStore = writable({
user: {
name: "Alice"
},
content: "This is a content."
})
function updateUserName() {
postStore.update(post => {
post.user.name = post.user.name + "+";
return post;
});
}
function updateUser() {
postStore.update(post => post)
}
</script>
<Post post={$postStore}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
見た目の動作は変わりませんが、updateUserName
でも updateUser
でも、以下のコンソールログが表示され、 post
自体が変わったと認識されていることがわかります。
post changed.
post.user changed.
どうやら、$
でストアの値を参照したときは、同じオブジェクトであっても値が変化したとみなすようです。
先ほどは $state
からストアに書き換えましたが、実際のプロジェクトではSvelte 4でストアを使っていたところはそのままに、UserやPostのコンポーネントの方をRuneを使った書き方に変えていくことが多いと思いますが、それでも動作は変わらなくて良いですね!
ところが、ハマったのはここからです。
$derived
は同じオブジェクトだと値が変化したと思ってくれない
先ほどのストアを使ったApp.svelteに $derived
を使った変数を追加して、そちらをPostコンポーネントに渡すようにします。
<script>
... //(省略)
let post = $derived($postStore);
</script>
<Post {post}/>
<button onclick={updateUserName}>updateUserName</button>
<button onclick={updateUser}>updateUser</button>
これだけで、updateUserName
を実行してもユーザー名が再描画されなくなってしまいました。
$
でストアの値を参照しているところは値が変化したとみなしても、 post
自身は値が変化したとみなされないためです。
そして、ストアに持たせたuserやそのnameといったプロパティは "deeply reactive" ではないので、Userコンポーネントでは再描画がされません。
{#each}
との組み合わせ
さらにハマった例を紹介します。
以下のコードでは、ストア内に配列が2重にあって、 2重の {#each}
でそれを表示しています。
update
で先頭グループの2人目のユーザー(Bob)の名前の後ろに "+" を付けているのですが、表示が更新されません。
なお、先頭に付けた <svelte:options runes />
を消すと、このコンポーネントではRuneを一切使っていないのでSvelte 4のコンポーネントとして動くのですが、そうすると表示は更新されるようになります。
<svelte:options runes /> <!-- ← Svelte 5モード。またはどこかで $stateなどを使っても同じ -->
<script>
import { writable } from "svelte/store";
const store = writable([
{
name: "Group 1",
users: [
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
],
},
{
name: "Group 2",
users: [
{ name: "Dorothy" },
],
},
]);
function update() {
store.update(groups => {
groups[0].users[1].name = groups[0].users[1].name + "+";
return groups;
})
}
</script>
{#each $store as group}
<h2>{group.name}</h2>
{#each group.users as user}
<div>{user.name}</div>
{/each}
{/each}
<button onclick={update}>Update</button>
原因ですが、どうやらSvelte 5では {#each}
に渡した配列は基本的には配列自体が同じオブジェクトであれば値が変化したとはみなさないようです。
ですが、渡した配列が $
でストアを参照したものであれば、配列自体が同じオブジェクトであっても値が変化したとみなすようです。
ところが、そこで as
で受け取った変数は、ちょうど先ほどの例の $derived
を使って作った変数のようになっていて、内側の {#each}
には $
のストア参照を渡しているわけではないので、ここは変化があったと思ってくれなくて再描画されないみたいです😢
{#each $store as group} <!-- ここはストア参照 -->
<h2>{group.name}</h2> <!-- 同じ配列でも再描画される -->
{#each group.users as user} <!-- ここはストア参照ではない -->
<div>{user.name}</div> <!-- 同じ配列なら再描画されない -->
{/each}
{/each}
まとめ
- Svelte 5では実行時に参照したものが変わったときに再描画・再評価される。
- トリガーとして含めたくない場合は
untrack
を使う。
- トリガーとして含めたくない場合は
- Runeで作った値はストアとは違って直接共有できない。
- 共有するにはクラスを使うか、値の取得・更新を関数経由で行う。
- ストアを同じオブジェクトを使って更新している処理は注意が必要。
-
$
で直接ストアを参照したときにのみ値が変わったと判断される。
-