Runesシステム入門

Runesとは

Runesは、Svelte 5で導入された新しいリアクティビティシステムです。従来のSvelte 3, 4ではlet宣言した変数が暗黙的にリアクティブになっていましたが、Runesでは$state$derivedなどの明示的な関数を使ってリアクティビティを宣言します。これにより、より予測可能でTypeScriptとの統合も優れた状態管理が実現されました。

主要なRunes

Runesは大きく分けて「状態管理」と「コンポーネント間通信」の2つのカテゴリに分類されます。

状態管理

  • $state - リアクティブな状態を定義。値が変更されるとUIが自動的に更新されます
  • $derived - 他の値から導出される計算値。依存する値が変更されると自動的に再計算されます
  • $effect - 副作用の実行。依存する値が変更されるたびに自動的に実行されます

コンポーネント間通信

  • $props - コンポーネントのプロパティを定義。親コンポーネントからデータを受け取ります
  • $bindable - 双方向バインディングを可能にし、親子間で値を同期します

Rune が使える場所と使えない場所

RunesはSvelteコンポーネント内でのみ使用可能で、サーバーサイドのコードや通常のTypeScriptモジュールでは使用できません。

ファイル/場所Rune 使用可否理由
+page.svelte✅ 使用可能UI と連動する状態を管理できる
+layout.svelte✅ 使用可能グローバルな UI 状態やヘッダー・ナビゲーションなどで有用
+page.ts / +layout.ts⚠️ 条件付きload() で Rune は使えないが、コンポーネント内で使う前提のデータ生成には使える場合がある
+page.server.ts❌ 使用不可SSR 実行時に1回限りで状態管理の意味がないため
+layout.server.ts❌ 使用不可同上
hooks.server.ts❌ 使用不可Rune のリアクティブ性が不要なサーバーロジック専用ファイル
通常の .ts モジュール(サーバー専用)❌ 使用不可Rune は Svelte runtime が動作するクライアント環境に依存する

Rune が必要な場面

Runesは以下のような場面で特に有効です。

  • インタラクティブなUIの構築: ユーザーがUI上でインタラクションするたびに状態が変わる場面(カウントアップ、チェック状態、フォーム入力など)
  • 計算値の管理: 複数の状態が依存し合うロジックを簡潔に書きたい場合($derivedを使った合計計算、フィルタリングなど)
  • 副作用の管理: DOM操作、ローカルストレージへの保存、APIコールなどをリアクティブに実行したい場合($effect

Rune が使えない場面(代替方法)

サーバーサイドの処理ではRunesは使用できません。以下の表は一般的なケースとその代替手段を示しています。

処理内容使用不可な例代替手段
認証トークンの取得+page.server.ts 内で $state を使うload() + event.locals を使って処理
DBへのアクセス+page.server.ts 内で $effect を使う通常の async 関数として記述
セッション情報の保持hooks.server.ts 内で $state を使うhandle フックで event.locals に保存

基本的な使い方

$state - 状態の定義

$stateはリアクティブな状態を宣言する最も基本的なRuneです。値が変更されると、その値を参照しているUIが自動的に更新されます。

コード展開

Click fold/expand codeをクリックするとコードが展開表示されます。

<script lang="ts">
  // プリミティブ値
  let count = $state(0);
  
  // オブジェクト
  let user = $state({
    name: '太郎',
    age: 25
  });
  
  // 配列
  let items = $state<string[]>([]);
</script>

<button onclick={() => count++}>
  カウント: {count}
</button>
svelte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Click fold/expand code

$derived - 計算値

$derivedは他のリアクティブな値から自動的に計算される値を作成します。依存する値が変更されるたびに、自動的に再計算されます。

合計: 200円

税込: 円

<script lang="ts">
  let price = $state(100);
  let quantity = $state(2);
  
  // priceやquantityが変更されると自動的に再計算
  let total = $derived(price * quantity);
  
  // 複雑な計算も可能
  let summary = $derived(() => {
    const subtotal = price * quantity;
    const tax = subtotal * 0.1;
    return {
      subtotal,
      tax,
      total: subtotal + tax
    };
  });
</script>

<p>合計: {total}</p>
<p>税込: {summary.total}</p>
svelte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Click fold/expand code

$effect - 副作用

$effectはリアクティブな値の変更に応じて副作用を実行します。DOM操作、ログ出力、外部APIへの通信などに使用されます。

<script lang="ts">
  let count = $state(0);
  
  // countが変更されるたびに実行
  $effect(() => {
    console.log(`カウント: ${count}`);
    document.title = `カウント: ${count}`;
    
    // クリーンアップ関数(オプション)
    return () => {
      console.log('クリーンアップ');
    };
  });
</script>
typescript

Svelte 4との違い

Svelte 4からSvelte 5への移行で、リアクティビティの書き方が大きく変わりました。以下は同じ機能を実装した例です。

古い書き方(Svelte 4)

<script>
  let count = 0;
  $: doubled = count * 2;
  
  $: {
    console.log(`Count: ${count}`);
  }
</script>
svelte

新しい書き方(Svelte 5)

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
  
  $effect(() => {
    console.log(`Count: ${count}`);
  });
</script>
svelte

なぜRunesを使うのか

Svelte 3, 4でのリアクティビティは便利でしたが、いくつかの問題がありました。Runesはこれらの問題を解決します。

1. 明示的なリアクティビティ

Svelte 3, 4では、どの変数がリアクティブかが一目では分かりませんでした。Runesでは$stateを使うことで、リアクティブな変数が明確になります。

// 一目でリアクティブな値とわかる
let count = $state(0);
let normalValue = 0; // 通常の変数
typescript

2. TypeScriptとの相性

RunesはTypeScriptと完璧に統合されており、型推論が正確に動作します。以下の例では、selectedの型が自動的にItem[]と推論され、IDEでの補完も完璧に動作します。

let items = $state<Item[]>([]);
let selected = $derived(() => 
  items.filter(item => item.selected)
);
typescript

3. 予測可能な動作

$derived$effectは依存関係を自動的に追跡し、必要な時だけ再実行されます。これにより、パフォーマンスが向上し、バグも減少します。

依存関係が自動的に追跡され、必要な時だけ再実行されます。

実践例:TODOリスト

Runesシステムを使った実際のTODOリストアプリケーションです。$state$derived$effectの組み合わせを確認できます。

TODOリスト(Runesシステム)

  • Svelte 5を学習する
  • Runesシステムを理解する

📊 残りのタスク: 2

✅ 完了率: 0%

TodoList.svelte
<script lang="ts">
  type Todo = {
    id: string;
    text: string;
    done: boolean;
  };
  
  let todos = $state<Todo[]>([
    { id: '1', text: 'Svelte 5を学習する', done: false },
    { id: '2', text: 'Runesシステムを理解する', done: false }
  ]);
  let newTodoText = $state('');
  
  // 完了していないTODOの数
  let remainingCount = $derived(
    todos.filter(t => !t.done).length
  );
  
  // 完了率
  let completionRate = $derived(() => {
    if (todos.length === 0) return 0;
    const completed = todos.filter(t => t.done).length;
    return Math.round((completed / todos.length) * 100);
  });
  
  // 統計情報のログ出力(副作用の例)
  $effect(() => {
    console.log(`TODOs: ${todos.length}個、残り: ${remainingCount}`);
  });
  
  function addTodo() {
    if (!newTodoText.trim()) return;
    
    todos = [...todos, {
      id: Date.now().toString(),
      text: newTodoText,
      done: false
    }];
    
    newTodoText = '';
  }
  
  function toggleTodo(id: string) {
    todos = todos.map(todo => 
      todo.id === id ? { ...todo, done: !todo.done } : todo
    );
  }
  
  function deleteTodo(id: string) {
    todos = todos.filter(todo => todo.id !== id);
  }
</script>

<div class="todo-container">
  <h2>TODOリスト(Runesシステム)</h2>
  
  <div class="input-group">
    <input 
      bind:value={newTodoText}
      onkeydown={(e) => e.key === 'Enter' && addTodo()}
      placeholder="新しいTODOを入力"
      class="todo-input"
    />
    <button onclick={addTodo} class="add-btn">追加</button>
  </div>
  
  {#if todos.length > 0}
    <ul class="todo-list">
      {#each todos as todo (todo.id)}
        <li class="todo-item">
          <input
            type="checkbox"
            checked={todo.done}
            onchange={() => toggleTodo(todo.id)}
            class="todo-checkbox"
          />
          <span class:done={todo.done} class="todo-text">
            {todo.text}
          </span>
          <button onclick={() => deleteTodo(todo.id)} class="delete-btn">
            削除
          </button>
        </li>
      {/each}
    </ul>
    
    <div class="stats">
      <p>📊 残りのタスク: <strong>{remainingCount}</strong></p>
      <p>✅ 完了率: <strong>{completionRate()}</strong>%</p>
      <div class="progress-bar">
        <div class="progress-fill" style="width: {completionRate()}%"></div>
      </div>
    </div>
  {:else}
    <p class="empty-message">TODOがありません。新しいタスクを追加してください。</p>
  {/if}
</div>

<style>
  .todo-container {
    max-width: 500px;
    margin: 0 auto;
    padding: 2rem;
    background: #f9f9f9;
    border-radius: 8px;
  }
  
  h2 {
    color: #ff3e00;
    margin-bottom: 1rem;
  }
  
  .input-group {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1.5rem;
  }
  
  .todo-input {
    flex: 1;
    padding: 0.5rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
  }
  
  .add-btn {
    padding: 0.5rem 1rem;
    background: #ff3e00;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
  }
  
  .add-btn:hover {
    background: #ff5a00;
  }
  
  .todo-list {
    list-style: none;
    padding: 0;
    margin: 0 0 1.5rem 0;
  }
  
  .todo-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.75rem;
    background: white;
    border-radius: 4px;
    margin-bottom: 0.5rem;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }
  
  .todo-checkbox {
    width: 20px;
    height: 20px;
    cursor: pointer;
  }
  
  .todo-text {
    flex: 1;
    font-size: 1rem;
  }
  
  .todo-text.done {
    text-decoration: line-through;
    opacity: 0.5;
    color: #666;
  }
  
  .delete-btn {
    padding: 0.25rem 0.5rem;
    background: #dc3545;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.875rem;
  }
  
  .delete-btn:hover {
    background: #c82333;
  }
  
  .stats {
    padding: 1rem;
    background: white;
    border-radius: 4px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }
  
  .stats p {
    margin: 0.5rem 0;
    color: #333;
  }
  
  .stats strong {
    color: #ff3e00;
  }
  
  .progress-bar {
    width: 100%;
    height: 20px;
    background: #e9ecef;
    border-radius: 10px;
    overflow: hidden;
    margin-top: 0.5rem;
  }
  
  .progress-fill {
    height: 100%;
    background: linear-gradient(90deg, #ff3e00 0%, #ff5a00 100%);
    transition: width 0.3s ease;
  }
  
  .empty-message {
    text-align: center;
    color: #666;
    padding: 2rem;
    background: white;
    border-radius: 4px;
  }
</style>
svelte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
Click fold/expand code

ベストプラクティス

1. 初期値の型を明示

// ❌ 型推論に頼る
let items = $state([]);

// ✅ 明示的な型定義
let items = $state<Item[]>([]);
typescript

2. $derivedは純粋に

// ❌ 副作用を含む
let value = $derived(() => {
  localStorage.setItem('key', 'value'); // 副作用
  return calculateValue();
});

// ✅ 純粋な計算のみ
let value = $derived(calculateValue());

// 副作用は$effectで
$effect(() => {
  localStorage.setItem('key', value);
});
typescript

3. クリーンアップを忘れずに

$effect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);
  
  // クリーンアップ関数を返す
  return () => {
    clearInterval(timer);
  };
});
typescript

次のステップ

各Runeの詳細な使い方を学びましょう。

  1. $state - 状態管理
  2. $derived - 計算値
  3. $effect - 副作用
  4. $props - プロパティ
  5. $bindable - 双方向バインディング
ディープダイブ

Runesシステムの各機能の詳細な比較と使い分けについては、以下のガイドもご参照ください:

Last update at: 2025/08/14 07:39:43