12k
All articles

Svelte 5のRunesによるState Management

Svelte 5のrunesによる状態管理: $state、$derived、$effectでコンポーネント間のstate共有とSvelteKitのSSR漏れを防ぎます。

OpenReplay Team
OpenReplay Team
Svelte 5のRunesによるState Management

Runesはコンパイラディレクティブであり、ランタイムのimportではありません。これによりリアクティビティが明示的かつポータブルになります。$stateはリアクティブなセルを宣言し、$derivedはそこから値を算出し、$effectは変更に反応します。これら3つが組み合わさることで、Svelte 4の暗黙的な$:宣言とwritable storeをすべての役割において置き換えます。SvelteのドキュメントではRunesをコンパイラが認識するキーワードとして説明しています。そのため、エイリアスの設定、import、条件付きの呼び出しはできません。$stateを単一のコンポーネントに組み込むことは簡単です。難しいのは、本番環境で問題が発生しやすい部分です。つまり、複数のコンポーネントが共有するstateをリアクティビティを失わずに構造化し、SvelteKitがサーバーでレンダリングする際にそのstateを適切にスコープすることです。

本記事では、state managementを一本の軸として扱います。$stateによるローカルstate、$derivedによる算出state、$effectによるサイドエフェクト、そして多くのガイドが省略している部分——.svelte.tsモジュールを通じたファイル間のstate共有、SSRにおけるリクエストごとのスコープ管理、各ステップで適切なツールを選ぶための判断基準——を順に解説します。一貫したテーマとして、Svelte 5のリアクティビティは値ごとのオプトイン方式であり、ほぼすべての落とし穴はプロキシが停止する境界から生じます。クラスインスタンス、分割代入されたバインディング、ESMのletエクスポートはいずれもプロキシの境界外に位置します。本記事で参照するバージョンはSvelte 5.xおよびSvelteKit 2.xを対象としています。

重要なポイント

  • 既存のstateの純粋な関数として表現できる値はすべて$effectではなく$derivedに属します。あるstateを別のstateから同期するために$effectを使うことは、このRuneの最も一般的な誤用です。
  • .svelte.tsモジュールから再代入可能な$stateをエクスポートすると、state_invalid_exportコンパイルエラーが発生します。公式に認められた2つの修正方法は、stateを返す関数をエクスポートするか、constオブジェクトまたはクラスインスタンスをエクスポートしてそのプロパティを変更することです。
  • アプリ全体のクライアントstateに推奨されるパターンは、$stateフィールドを持つクラスをconstインスタンスとしてエクスポートすることです。constバインディングは再代入されないため、state_invalid_exportの問題が解消されます。
  • .svelte.tsモジュールのトップレベルで$stateを宣言すると、サーバープロセスごとに1つの共有インスタンスが作成されます。SvelteKitのSSRではこれがユーザー間のstate漏洩リスクになります。リクエストごとのstateはloadから返し、setContext/getContextを通じて共有することでスコープを適切に管理してください。
  • $stateオブジェクトを分割代入すると、リアクティブなバインディングではなく、分割代入時点の値がキャプチャされます。リアクティビティを保持するには、プロキシを通じて読み取るか、ゲッター関数を渡してください。

$stateとは何か、ローカルのリアクティブstateはどのように機能するか

$stateはリアクティブなセルを宣言します。読み取りは依存関係を登録し、書き込みは更新をスケジュールします。プリミティブ型は通常通り代入・再代入でき、オブジェクトや配列に対してはSvelteが深いプロキシを返すため、直接の変更が追跡されます。$stateのドキュメントによると、プレーンなオブジェクトや配列を渡すと深いリアクティビティが得られ、インプレースで変更すると依存するUIが更新されます。

<script lang="ts">
  let count = $state(0);
  let todos = $state([{ id: 1, text: 'Learn runes', done: false }]);

  function addTodo() {
    todos.push({ id: Date.now(), text: 'New todo', done: false }); // 追跡される
  }

  function toggle(id: number) {
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done; // プロパティへの代入が追跡される
  }
</script>

<button onclick={() => count++}>Clicked {count} times</button>

深いリアクティビティを持つ$stateオブジェクトや配列では、プロキシが任意の深さで変更をインターセプトするため、push()と直接のプロパティ代入の両方が機能します。ドキュメントには1つの境界が記載されています。プロキシ化はクラスインスタンスで停止します。プレーンなオブジェクトや配列は深いリアクティビティを持ちますが、クラスインスタンスはそのフィールド自体が$stateで宣言されていない限り、深いリアクティビティを持ちません。

大きなフラットなデータを変更せず丸ごと置き換える場合は、$state.rawを使用してください。これは変更ではなく再代入のみを追跡します。

<script lang="ts">
  let payload = $state.raw(largeApiResponse);

  // payload.foo = 'x';  // 更新なし — 変更は追跡されない
  payload = { ...payload, foo: 'x' }; // 更新が発火 — 再代入は追跡される
</script>

$state.rawはプロキシの作成をスキップするため、大きなフラットなオブジェクトに対する深いプロキシのプロパティごとのディスクリプタオーバーヘッドを回避できます。値が大きく、実質的にイミュータブルで、単位として置き換えられる場合——パースされたJSONレスポンス、設定ブロブ、ルックアップテーブルなど——に使用してください。それ以外の場合はデフォルトの$stateを使用してください。

$derived$derived.byはどのように算出stateを扱うか

$derivedはリアクティブなstateから算出された値を宣言し、依存関係が変化すると自動的に更新されます。Svelte 4の$:リアクティブ宣言を置き換えるものです。$derivedのドキュメントによると、依存関係のいずれかが変化した後、次に読み取られたときに再計算されます。Svelte 5.25以降、constでない$derivedは一時的に再代入して値をオーバーライドすることもできます。これはオプティミスティックUIに有用で、依存関係が次に変化したときに算出値に戻ります。

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
  let isEven = $derived(count % 2 === 0);
</script>

ループ、条件分岐、または複数のステートメントが必要な計算には、式の代わりに関数を受け取る$derived.byを使用してください。

<script lang="ts">
  let items = $state([{ price: 10, quantity: 2 }]);

  let summary = $derived.by(() => {
    let total = 0;
    let count = 0;
    for (const item of items) {
      total += item.price * item.quantity;
      count += item.quantity;
    }
    return { total, count };
  });
</script>

ほとんどのリアクティビティバグを防ぐルール:既存のstateの純粋な関数として表現できる値はすべて$effectではなく$derivedに属します。あるstateを別のstateから同期するために$effectを使うと、冗長なリアクティブサイクルが生じ、このRuneの最も一般的な誤用となります。あるstateから別のstateをセットするeffectを書いていることに気づいたら、$derivedに置き換えてください。

$effectはいつ使うべきか

$effectはサイドエフェクト専用です——サブスクリプション、ロギング、手動のDOM操作、永続化——値の導出には使いません。$effectのドキュメントによると、effectはDOMが更新された後に実行され、内部で読み取ったリアクティブな値を自動的に追跡し、それらの値が変化すると再実行され、ブラウザでのみ実行されSSR中は実行されません。依存関係の配列はなく、Svelteは読み取った内容から依存関係を検出します。

<script lang="ts">
  let count = $state(0);

  $effect(() => {
    const id = setInterval(() => console.log('tick', count), 1000);
    return () => clearInterval(id); // 再実行前と破棄時にクリーンアップが実行される
  });
</script>

返される関数はクリーンアップコールバックです——effectが再実行される前とコンポーネントが破棄されるときに実行されます。ここでインターバル、リスナー、サブスクリプションを解除します。effectはSSRを完全にスキップするため、サーバーレンダリングの出力を生成するためにeffectに依存しないでください。その処理はload関数か$derivedに属します。

覚えておくべき唯一の誤用:stateをコピーするために$effectを使わないでください。$effect(() => { fullName = `${first} ${last}` })と書くと書き戻しループが生じますが、$derivedを使えば正しく、かつよりシンプルになります。

<script lang="ts">
  let first = $state('Ada');
  let last = $state('Lovelace');
  let fullName = $derived(`${first} ${last}`); // 正しい — effectは不要
</script>

Svelte 5でコンポーネント間のstateを共有するには

ファイル間でリアクティブなstateを共有するには、.svelte.tsモジュールに配置します。ただし、再代入可能な$stateバインディングを直接エクスポートすることはできません。直感的なパターンは失敗します。

// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
  count += 1;
}

このようにモジュールから再代入可能な$stateバインディングをエクスポートすると、ドキュメントに記載されているstate_invalid_exportコンパイルエラーが発生します。エラーメッセージは次の通りです:“Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value’s properties.” トリガーはimport自体ではなく再代入(count += 1)です——エクスポートされた$stateが再代入されているためエラーが発生しますが、これはESMのletエクスポートがモジュール境界を越えて安全に行えない操作です。公式に認められた修正方法は2つあります。

修正方法1 — constオブジェクトをエクスポートしてプロパティを変更する。 バインディングは再代入されず、プロパティのみが変更されるため、すべてのimporter側が同じプロキシを読み取ります。

// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
  counter.count += 1; // プロパティの変更であり、再バインディングではない
}

修正方法2 — stateをファイルローカルに保ちゲッターをエクスポートする。 これによりセルをプライベートに保ち、外部からの再代入を防ぎます。

// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
  return count;
}
export function increment() {
  count += 1;
}

アプリ全体のクライアントstateに推奨されるパターンは、修正方法1の最もクリーンなバージョンです。$stateフィールドを持つクラスをconstインスタンスとしてエクスポートします。constバインディングは再代入されないためstate_invalid_exportの問題が解消され、インスタンスをimportするすべてのコンポーネントが同じリアクティブプロキシから読み取ります。

// src/lib/todo-store.svelte.ts
class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
}

class TodoStore {
  items = $state<Todo[]>([]);
  filter = $state<'all' | 'active' | 'done'>('all');

  get visible(): Todo[] {
    if (this.filter === 'all') return this.items;
    const wantDone = this.filter === 'done';
    return this.items.filter((t) => t.done === wantDone);
  }

  add(text: string) {
    this.items.push(new Todo(text));
  }

  remove(todo: Todo) {
    this.items = this.items.filter((t) => t !== todo);
  }
}

export const todoStore = new TodoStore();

todoStoreをimportするすべてのコンポーネントが同じリアクティブインスタンスを読み取り・変更できます——contextの配線もサブスクリプションのボイラープレートも不要です。$state<Todo[]>([])の型パラメータに注目してください。TypeScriptは初期値から型を推論しますが、空の配列、空のオブジェクト、または初期値より広いユニオン型の場合は明示的なパラメータを渡します。このパターンにはSSRに関する重要な注意点があります。後述します。

クラス内での$stateの動作

$stateはクラスフィールドとして機能し、コンパイラは各フィールドをプライベートシグナルをバックエンドとするプロトタイプのget/setアクセサペアに変換します。$stateのクラスに関するドキュメントにはこの変換が説明されており、知っておくべき2つの結果があります。アクセサはインスタンスではなくプロトタイプに存在するため、Object.keys(instance)はリアクティブフィールドを列挙せず、{ ...instance }でスプレッドしても省略されます。

class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
  toggle() {
    this.done = !this.done;
  }
}

誰もが陥る落とし穴は、イベントハンドラでのthisバインディングです。メソッドの参照を渡すとインスタンスから切り離され、ドキュメントでもこのケースが直接取り上げられています。

<!-- this === <button>であり、Todoインスタンスではない — 壊れている -->
<button onclick={todo.toggle}>toggle</button>

<!-- this === todo — 正しく動作する -->
<button onclick={() => todo.toggle()}>toggle</button>

読み取りと書き込みはプロトタイプアクセサを経由するため、メソッドは正しいthisを必要とします。バインドを維持する修正方法は2つあります。呼び出し元でアロー関数でラップする(() => todo.toggle())か、thisをクローズオーバーするアロー関数フィールドとしてメソッドを定義します。

class Todo {
  done = $state(false);
  toggle = () => {
    this.done = !this.done; // アロー関数フィールド — `this`は永続的にバインドされる
  };
}

メソッドを参照として渡す場合はアロー関数フィールド形式を使用し、常にtodo.toggle()として呼び出す場合は通常のメソッドを使用してください。

Runesにおける主なリアクティビティの落とし穴

Runesのバグのほとんどは1つのルールに起因します。リアクティビティは値ごとのオプトイン方式であり、プロキシはクラスインスタンス、分割代入されたバインディング、ESMエクスポートで停止します。リアクティビティが失われる原因の大部分を占める3つの落とし穴があります。

分割代入は値をキャプチャするものであり、バインディングではない。 $stateオブジェクトを分割代入すると、分割代入時点の値が読み取られます。リアクティブな参照は作成されません。

const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.nameは更新される
console.log(name); // まだ'Ada' — 分割代入時にキャプチャされた値

リアクティビティを保持するには、元のプロキシを通じて読み取る(user.name)か、プロキシをクローズオーバーして呼び出しのたびに再読み取りするゲッター関数(() => user.name)を渡してください。これは変数にリファクタリングした後に値が「更新されなくなった」場合に適用すべき修正方法です。

ネイティブのMapSetDateURLはクラスインスタンスであるため、プロキシはそこで停止する。 svelte/reactivityのリアクティブな同等品を使用してください。これらは.size.get().has()、イテレーションの読み取りを追跡します:SvelteMapSvelteSetSvelteDateSvelteURLSvelteURLSearchParamsです。

import { SvelteMap } from 'svelte/reactivity';

const cache = new SvelteMap<string, number>();
cache.set('a', 1); // リアクティブ

ドキュメントには1つの注意点があります。リアクティブなMapSet内部に格納された値は深いリアクティビティを持ちません。SvelteMapにプレーンなオブジェクトを格納してそのプロパティをリアクティブに変更したい場合は、そのオブジェクトを先に$stateでラップしてください。

プロキシは特定のAPI境界を越えられない。 structuredClonepostMessage、一部のシリアライザはプロキシを拒否します。境界ではプレーンな静的コピーを取得するために$state.snapshotを使用してください。

await fetch('/api/save', {
  method: 'POST',
  body: JSON.stringify($state.snapshot(user)),
});

$state.snapshotはこれらの境界でのみ使用し、コード全体で使用しないでください——プロキシこそが他のすべての場所で必要なものです。

モジュールグローバルstateとSvelteのcontextはどちらを使うべきか

モジュールグローバルstateはクライアントのみのアプリでは安全ですが、SSRでは安全ではありません。リクエストスコープのstateはload関数に属し、SvelteのコンテキストAPIを通じて共有されます。トップレベルで$stateを宣言する.svelte.tsモジュールは、サーバープロセスごとに1つの共有インスタンスを作成します。クライアントではそれが望ましい動作です。SvelteKitのSSRではリクエスト漏洩のリスクになります。SvelteKitのstate managementドキュメントでは、サーバーは長期間稼働しユーザー間で共有されるため、共有モジュール変数にユーザーごとのデータを格納してはならないと述べており、あるユーザーのシークレットが別のユーザーのレンダリングに漏洩する典型的な例を示しています。

漏洩はこのように発生します——サーバーレンダリング中に変更されたモジュールstoreが、同じプロセスにヒットする次のリクエストから参照可能になります。

// src/lib/user.svelte.ts — SSRでは危険
export const currentUser = $state({ name: '' });
// +page.server.ts — SSR中に共有stateを設定する
import { currentUser } from '$lib/user.svelte';

export function load({ locals }) {
  currentUser.name = locals.user.name; // リクエスト間で漏洩する
}

正規の修正方法は、リクエストごとのデータをloadから返し、setContext/getContextを通じて共有することです。これにより値のスコープがプロセス全体のモジュールではなく、単一のコンポーネントツリー——つまり単一のリクエスト——に限定されます。

// +layout.server.ts
export function load({ locals }) {
  return { user: locals.user }; // リクエストごとのデータ
}
<!-- +layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  let { data } = $props();
  setContext('user', data.user); // このリクエストのコンポーネントツリーにスコープされる
</script>

本番のSvelteKitアプリでは、不適切にスコープされたモジュールレベルのstateはセッションリプレイにおいて、ユーザーが一瞬別のユーザーのデータや初期ロード時の古い値を目にするという形で現れます。これは、loadからリクエストごとのデータを返してcontextを通じて共有するのではなく、モジュールグローバルに保持されたstateの可視的な症状です。このような実装のセッションリプレイでは、ハイドレーション後のstateだけでなく初期のサーバーレンダリングされたDOMもキャプチャするため、バグが表面化します。

この軸全体をつなぐ判断基準:

1つのコンポーネントが所有する値にはコンポーネントローカルの$stateを使用し、そのstateから算出されるものには$derivedを使用し、アプリ全体のクライアントstateにはモジュールレベルのクラスstore(export const store = new Store())を使用し、ユーザー間で漏洩してはならないリクエストスコープまたはSSRのstateにはloadから返してからsetContext/getContextで共有するリクエストごとのデータを使用してください。

Propsの配線——$props$bindable、デバッグ用の$inspect——はこのstate managementの軸の外に位置します。stateコンテナではなく、コンポーネントのインターフェースとデバッグツールとして扱ってください。

Svelte 4のstoreからRunesへの移行

Svelte 4のリアクティビティパターンのほとんどはRunesに直接マッピングできます。writable storeが担っていた共有stateのケースも含まれます。以下の表は中級開発者が最もよく直面する変換をカバーしています。完全な内容はSvelte 5移行ガイドを参照してください。

Svelte 4Svelte 5 Runes
let count = 0;let count = $state(0);
$: doubled = count * 2;let doubled = $derived(count * 2);
$: console.log(count);$effect(() => console.log(count));
export let name = 'world';let { name = 'world' } = $props();
const count = writable(0); $count++let count = $state(0); count++
コンポーネント間で共有されるwritable storeexport const store = new Store()としてエクスポートされるクラスstore
SSRレイアウトでユーザーごとのデータのためにサブスクライブされるstoreloadがデータを返す → setContext/getContext

最後の2行は多くのガイドが省略している部分です。アプリ全体で共有されるwritable storeはconstとしてエクスポートされるクラスインスタンスになり、SSRコンテキストでのユーザーごとのstoreはloadから返されてcontextを通じて渡されるリクエストごとのデータになります。

Runesはリアクティビティモデルを明示的にしますが、真の価値はアーキテクチャにあります。stateはできる限りローカルに保ち、複数のコンポーネントが本当に共有する場合にのみモジュールレベルのクラスstoreに昇格させ、SSRが関わる瞬間にcontextを通じてスコープを管理してください。サーバーレンダリングに関与するトップレベルの$stateについて.svelte.tsモジュールを監査してください——この単一のチェックがSvelteKitにおける最も深刻なstateバグのクラスを捕捉します。

FAQ

Svelte 5における$state.rawと$stateの違いは何ですか?

$stateは任意の深さで変更を追跡する深いプロキシを返すため、push()とプロパティへの代入が更新をトリガーします。$state.rawはプロキシの作成をスキップして再代入のみを追跡するため、インプレースの変更は無視され、更新をトリガーするには値を丸ごと置き換える必要があります。パースされたJSONレスポンス、設定ブロブ、ルックアップテーブルなど、大きく実質的にイミュータブルで単位として置き換えられるデータに対して、プロパティごとのプロキシオーバーヘッドを省略することが重要な場合に$state.rawを使用してください。それ以外の場合はデフォルトの$stateを使用してください。

分割代入後に$stateの値が更新されなくなるのはなぜですか?

$stateオブジェクトを分割代入すると、分割代入時点の値が読み取られ、プレーンな非リアクティブな変数が作成されます。プロキシへのリアクティブなバインディングは作成されません。const name = user.nameの後にuser.nameを変更すると、プロキシは更新されますが、分割代入されたnameは元の値のまま固定されます。リアクティビティを保持するには、使用時点でuser.nameとして元のプロキシを通じて読み取るか、プロキシをクローズオーバーして呼び出しのたびに再読み取りする() => user.nameのようなゲッター関数を渡してください。

SvelteKitでSSR中に$effectコールバックは実行されますか?

いいえ。$effectコールバックはブラウザでのみ実行され、サーバーサイドレンダリング中は実行されません。effectはDOMが更新された後に実行され、読み取ったリアクティブな値が変化すると再実行されます。つまり、サーバーレンダリングされたHTMLに貢献することはできません。SSR中に出力を生成するためにeffectに依存しないでください。その処理はload関数か$derivedに配置してください。effectはサブスクリプション、ロギング、手動のDOM操作、インターバル、永続化などのブラウザ専用のサイドエフェクトのためのものであり、オプションのクリーンアップコールバックを返すことができます。

SvelteMapやSvelteSetの内部に格納された値は深いリアクティビティを持ちますか?

いいえ。svelte/reactivityのSvelteMapとSvelteSetは.size、.get()、.has()、イテレーションの読み取りを追跡し、エントリの追加や削除に反応しますが、内部に格納された値は深いリアクティビティを持ちません。SvelteMapにプレーンなオブジェクトを格納してそのプロパティの変更が更新をトリガーすることを期待する場合は、そのオブジェクトを先に$stateでラップして内部オブジェクトをリアクティブプロキシにしてください。リアクティブコレクションは構造を追跡しますが、格納された任意の値の内部stateは追跡しません。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.