誰も語らないStoreの問題
Svelteアプリを数ヶ月間構築してきた。すべてがスムーズに感じられる — コンポーネントツリーが3階層を超えて深くなるまでは。気づけばpropsを通じてStoreを渡し回し、コンポーネント以外のファイルでget()をインポートし、なぜwritable()の更新が存在すら忘れていた子コンポーネントで3回の予期しない再レンダリングを引き起こしたのかをデバッグしている。
これが昨年のダッシュボードプロジェクトでの私の状況だった。userStore、settingsStore、notificationsStoreがあり、互いに依存したDerived Storeが絡み合っていた。ロジックは紙の上では理解できたが、6週間後にそのコードを読み返すのは考古学のようだった。
Svelte 5 Runesは、このパターンが規模拡大時に破綻するからこそ存在する。そして、これは単なる構文糖衣ではない — リアクティビティモデル全体を置き換えるものだ。
Storeの本当の問題点
Svelte Storeは壊れていない。ちゃんと動く。しかし、アプリが成長するにつれて積み重なる本当の摩擦がある:
$サブスクリプションプレフィックスはマジック構文 —.svelteファイルの中でしか機能しない。プレーンな.tsユーティリティやクラスでリアクティブなステートが必要な瞬間、get()を呼び出して手動でサブスクライブしなければならない。- Derived Storeは冗長 —
derived([storeA, storeB], ([a, b]) => a + b)は動作するが、概念的にシンプルなものに対してノイズが多い。 - Storeは変数ではなく値 —
userStore.name = 'Alice'と書くだけでは動かない。update()やset()を呼び出す必要があり、直感的なメンタルモデルを壊す。 - 副作用の置き場所がない — Svelte 4のリアクティブ文における
$: { someEffect() }は変更のたびに実行されるが、組み込みのクリーンアップメカニズムがない。
これらのどれも単体では致命的ではない。しかし、合わさると摩擦が積み重なり、最終的には機能を実際に構築するよりもStoreのボイラープレートを管理することに多くの時間を費やすことになる。
Svelte 5 Runes:別の基盤
Runesはコンパイラディレクティブだ。Svelteコンパイラはビルド時にそれらを変換する — そして$ストアプレフィックスとは異なり、JavaScriptが動作するどこでも機能する:.svelteファイル、.svelte.tsユーティリティモジュール、プレーンな.tsファイル。
最もよく使う3つは$state、$derived、$effectだ。
$state:変数のように扱えるリアクティブ変数
Writableなストアを作成してサブスクライブする代わりに、リアクティブなステートを直接宣言する:
// Svelte 4 — Storeアプローチ
import { writable } from 'svelte/store';
const count = writable(0);
// コンポーネント内: $countで読み取り、count.set(1)またはcount.update(n => n + 1)で書き込み
// Svelte 5 — Runeアプローチ
let count = $state(0);
// 読み取り: count — 書き込み: count = 1またはcount++
// 以上。
代入構文が自然に感じられるのは、それが本当に自然だからだ。Svelteのコンパイラが$state変数への代入をインターセプトし、自動的にリアクティビティをトリガーする。.set()も.update()も不要。
オブジェクトと配列はデフォルトで深くリアクティブだ:
let user = $state({ name: 'Alice', age: 30 });
// リアクティビティがトリガーされる — スプレッドや再代入は不要
user.age = 31;
user.name = 'Bob';
Svelte 4と比較してみよう。Svelte 4ではリアクティブな更新を確実にトリガーするためにオブジェクト全体を再代入しなければならない場合があった。
$derived:ノイズのない計算値
Svelte 4のDerived Storeはderivedをインポートしてすべてをコールバックでラップすることを意味した。Runesでは、計算されたステートは他の式と同様に読み書きできる:
// Svelte 4
import { derived } from 'svelte/store';
const fullName = derived(
[firstName, lastName],
([$first, $last]) => `${$first} ${$last}`
);
// Svelte 5
let firstName = $state('Alice');
let lastName = $state('Smith');
let fullName = $derived(`${firstName} ${lastName}`);
Svelteはデリバ式がどの$state変数を読み取るかを追跡し、それらが変更されたときのみ再実行する。手動の依存配列もget()呼び出しも不要。
より複雑な派生ロジックには$derived.by()を使う:
let cart = $state([{ price: 20 }, { price: 35 }, { price: 15 }]);
let total = $derived.by(() => {
return cart.reduce((sum, item) => sum + item.price, 0);
});
$effect:クリーンアップのメカニズムを持つ副作用
これが私のプロジェクトで最も複雑なコードをクリーンにしたものだ。Svelte 4の$: { ... }リアクティブブロックは副作用を実行したが、クリーンアップの仕組みがなかった。その中でイベントリスナーやWebSocketをセットアップすると、それを破棄するために別途onDestroy呼び出しと注意深い手動調整が必要だった。
// Svelte 5
let userId = $state(null);
$effect(() => {
if (!userId) return;
const socket = new WebSocket(`wss://api.example.com/user/${userId}`);
socket.onmessage = (e) => handleMessage(e.data);
// クリーンアップ関数を返す — エフェクトが再実行される前またはDestroy時に実行される
return () => {
socket.close();
};
});
userIdが変更されると、Svelteはまずクリーンアップを実行し、その後エフェクトを再実行する。onDestroyの格闘は不要。ライフサイクルはエフェクトの中に収まっている — まさにあるべき場所に。
コンポーネント間でのステート共有:グローバルStoreをRunesで置き換える
これはStoreから離れる際に正しく理解しておくべき最も有用なパターンだ。グローバルなWritableは関連のないコンポーネント間でステートを共有するための標準的な答えだった。Runesでは、.svelte.tsファイルに共有リアクティブステートを作成する:
// state/user.svelte.ts
let _user = $state(null);
let _loading = $state(false);
export function getUser() {
return _user;
}
export async function fetchUser(id) {
_loading = true;
try {
_user = await api.getUser(id);
} finally {
_loading = false;
}
}
export function isLoading() {
return _loading;
}
// 任意のコンポーネントで
import { getUser, fetchUser, isLoading } from './state/user.svelte.ts';
let user = $derived(getUser());
let loading = $derived(isLoading());
覚えておくべき重要な点:モジュール内の$stateは共有ステートだ。インポートごとに1インスタンス — 他のモジュールレベル変数と同じ。$derivedを通じて読み取る任意のコンポーネントは、変更時に再レンダリングされる。
実際には、このパターン — エクスポートされたゲッター/セッター関数を持つモジュールレベルの$state — はSvelte 5に移行する際に最初に慣れておくべきものだ。プレーンな関数のテスト容易性、Storeのリアクティビティ、そして通常の変数の可読性を手に入れられる。
実践的な移行:Svelte 4 Store → Svelte 5 Runes
既存のアプリを移行する?対応関係は明快だ:
writable(initialValue)→$state(initialValue)$storeサブスクリプションプレフィックス → 削除して変数をそのまま使うstore.set(value)→ 直接代入:value = newValuederived([a, b], fn)→$derived(expression)$: { sideEffect() }→$effect(() => { sideEffect() })- グローバルなWritable Store →
.svelte.tsファイルのモジュールレベル$state
Readable Storeはあまり一般的ではない。ゲッターのみをエクスポートした$stateに対応する — パブリックAPIにセッターはない。
Storeがまだ有効な場面
Runesはすべての状況でStoreを引退させるわけではない。Svelte 4と5の両方をターゲットにするライブラリはStoreを使い続けるべきだ — それが互換性のあるAPIだから。Store契約の周りに構築されたサードパーティの統合(例えばいくつかのアニメーションライブラリ)についても同様だ。
しかし、Svelte 5コードベースにおけるアプリケーションレベルのステートについては?私が遭遇したほぼすべてのシナリオでRunesがよりクリーンな道だ。
メンタルモデルの転換
最大の調整は構文ではない。リアクティブなステートが必要なたびにStoreに手を伸ばす習慣を断つことだ。Storeは長年にわたって「公式の」リアクティブプリミティブであったため、そう感じられた。
Svelte 5では、リアクティブプリミティブは単なる変数だ — $stateで宣言される。それが腑に落ちれば、残りは自然に収まる:計算値は式($derived)、副作用はクリーンアップを意識した関数($effect)、そして共有ステートはリアクティブ変数を持つモジュールだ。
Runesは、Storeがかつてそうであったよりも本当にプレーンなJavaScriptに近い。いくつかのプロジェクトを移行した今、writable()に押し戻されるようなエッジケースにぶつかるのを待ち続けている — まだそれは起きていない。

