$stateとProxyオブジェクト
Svelteの状態管理は基本的にはProxyオブジェクトを利用しています。
Proxyオブジェクトとは
Proxyは、オブジェクトへの操作を「横取り」して、カスタムの動作を定義できるJavaScriptの機能です。ES2015で導入され、オブジェクトの基本操作(読み取り、書き込み、削除など)をインターセプトできます。
// 基本的な使い方
const target = { value: 0 };
const proxy = new Proxy(target, {
get(target, property) {
console.log(`読み取り: ${String(property)}`);
return target[property];
},
set(target, property, value) {
console.log(`書き込み: ${String(property)} = ${value}`);
target[property] = value;
return true;
}
});
proxy.value; // "読み取り: value"
proxy.value = 10; // "書き込み: value = 10"
typescript
なぜSvelteはProxyを採用したのか
Svelte 5では、リアクティビティシステムの中核にProxyを採用しました。
これにより、
- 自然な文法: 通常のJavaScriptのように書ける
- 自動追跡: 依存関係を自動的に検出
- 細粒度の更新: 変更された部分のみを効率的に更新
実例:ショッピングカートの実装と、各アプローチの比較
同じショッピングカート機能を、3つの異なるアプローチで実装してみましょう。
RxJS(Angularスタイル)での実装
import { BehaviorSubject, combineLatest, map } from 'rxjs';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
class CartServiceRxJS {
private itemsSubject = new BehaviorSubject<CartItem[]>([]);
// 公開用のObservable
items$ = this.itemsSubject.asObservable();
// 合計金額の計算
total$ = this.items$.pipe(
map(items => items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
))
);
// アイテムの追加
addItem(item: Omit<CartItem, 'quantity'>) {
const currentItems = this.itemsSubject.value;
const existingItem = currentItems.find(i => i.id === item.id);
if (existingItem) {
existingItem.quantity++;
} else {
currentItems.push({ ...item, quantity: 1 });
}
this.itemsSubject.next([...currentItems]);
}
// 数量の更新
updateQuantity(id: number, quantity: number) {
const items = this.itemsSubject.value.map(item =>
item.id === id ? { ...item, quantity } : item
);
this.itemsSubject.next(items);
}
// アイテムの削除
removeItem(id: number) {
const items = this.itemsSubject.value.filter(item => item.id !== id);
this.itemsSubject.next(items);
}
}
// 使用例
const cart = new CartServiceRxJS();
cart.total$.subscribe(total => console.log(`合計: ¥${total}`));
cart.addItem({ id: 1, name: "商品A", price: 1000 });
typescript
メリット
- 明示的なデータフロー
- 強力な演算子(debounce、switchMapなど)
- 非同期処理との親和性が高い
デメリット
- 学習曲線が急
- ボイラープレートが多い
- サブスクリプション管理が必要
ビルトインクラスのリアクティブ拡張
Svelte 5では、ネイティブのビルトインクラスも$state()
と組み合わせることで自動的にProxyでラップされ、リアクティブになります。
// Map - キーバリューストアがリアクティブに
let userPreferences = $state(new Map<string, string>());
userPreferences.set('theme', 'dark'); // UIが自動更新
userPreferences.delete('oldKey'); // 削除も検知
// Set - 重複なしコレクションがリアクティブに
let selectedTags = $state(new Set<string>());
selectedTags.add('svelte'); // 追加を検知
selectedTags.clear(); // クリアも検知
// Date - 日時オブジェクトもリアクティブに
let deadline = $state(new Date());
deadline.setDate(deadline.getDate() + 7); // 1週間後に変更でUI更新
// URL - URL操作がリアクティブに
let apiUrl = $state(new URL('https://api.example.com'));
apiUrl.searchParams.set('page', '2'); // クエリパラメータ変更を検知
apiUrl.pathname = '/v2/users'; // パス変更も検知
// URLSearchParams - クエリパラメータ管理
let queryParams = $state(new URLSearchParams('sort=name&order=asc'));
queryParams.set('filter', 'active'); // パラメータ追加を検知
typescript
実践例:リアクティブなフィルター管理
このデモは、Svelte 5のProxyベースのリアクティビティシステムを実演します。
- Proxyによる配列の監視:
push()
やsplice()
などの破壊的メソッドも自動検知 - $derivedの自動計算:フィルター数が変更されると自動的に再計算
- オブジェクトのネストした更新:
filterState.categories
への変更がUIに即座に反映 - タグボタン:クリックすると選択状態が切り替わり、スタイルが動的に変更
チェックボックスやタグをクリックしてみてください。選択したフィルターが即座に反映され、アクティブフィルター数やクエリ文字列が自動更新されます。本番環境では実際のURL形式で、開発時は読みやすい形式で確認できます。
🔍 フィルター管理デモ
カテゴリー
価格帯
ブランド
タグ
📊 アクティブフィルター: 0個
クエリ文字列: (なし)
ReactiveFilters.svelte
<script lang="ts">
// フィルターオプションの定義
const filterOptions = {
category: ['電子機器', '書籍', '衣類', '食品', '家具'],
price: ['0-1000', '1000-5000', '5000-10000', '10000+'],
brand: ['Apple', 'Sony', 'Samsung', 'Nike', 'Adidas']
};
const tagOptions = ['新着', 'セール', '人気', '限定', 'おすすめ'];
// シンプルなオブジェクトで状態管理(Proxyでリアクティブに)
let filterState = $state({
categories: [] as string[],
prices: [] as string[],
brands: [] as string[],
tags: [] as string[]
});
// URLパラメータ
let queryParams = $state({
query: ''
});
// カテゴリフィルターの切り替え
function toggleCategory(category: string) {
const index = filterState.categories.indexOf(category);
if (index === -1) {
filterState.categories.push(category);
} else {
filterState.categories.splice(index, 1);
}
updateQueryParams();
}
// 価格フィルターの切り替え
function togglePrice(price: string) {
const index = filterState.prices.indexOf(price);
if (index === -1) {
filterState.prices.push(price);
} else {
filterState.prices.splice(index, 1);
}
updateQueryParams();
}
// ブランドフィルターの切り替え
function toggleBrand(brand: string) {
const index = filterState.brands.indexOf(brand);
if (index === -1) {
filterState.brands.push(brand);
} else {
filterState.brands.splice(index, 1);
}
updateQueryParams();
}
// タグの切り替え
function toggleTag(tag: string) {
const index = filterState.tags.indexOf(tag);
if (index === -1) {
filterState.tags.push(tag);
} else {
filterState.tags.splice(index, 1);
}
updateQueryParams();
}
// クエリパラメータを更新
function updateQueryParams() {
const params = new URLSearchParams();
if (filterState.categories.length > 0) {
params.set('category', filterState.categories.join(','));
}
if (filterState.prices.length > 0) {
params.set('price', filterState.prices.join(','));
}
if (filterState.brands.length > 0) {
params.set('brand', filterState.brands.join(','));
}
if (filterState.tags.length > 0) {
params.set('tags', filterState.tags.join(','));
}
queryParams.query = params.toString();
}
// アクティブフィルター数($derivedで自動計算)
let activeFilterCount = $derived(
filterState.categories.length +
filterState.prices.length +
filterState.brands.length +
filterState.tags.length
);
// すべてクリア
function clearAll() {
filterState.categories = [];
filterState.prices = [];
filterState.brands = [];
filterState.tags = [];
queryParams.query = '';
}
// 選択された商品(デモ用)
let selectedProducts = $derived(() => {
let result = [];
if (filterState.categories.length > 0) {
result.push(`カテゴリー: ${filterState.categories.join(', ')}`);
}
if (filterState.prices.length > 0) {
result.push(`価格帯: ¥${filterState.prices.join(', ¥')}`);
}
if (filterState.brands.length > 0) {
result.push(`ブランド: ${filterState.brands.join(', ')}`);
}
if (filterState.tags.length > 0) {
result.push(`タグ: ${filterState.tags.join(', ')}`);
}
return result;
});
</script>
<div class="filter-demo">
<h3>🔍 フィルター管理デモ</h3>
<div class="filter-section">
<h4>カテゴリー</h4>
{#each filterOptions.category as category}
<label>
<input
type="checkbox"
checked={filterState.categories.includes(category)}
onchange={() => toggleCategory(category)}
/>
{category}
</label>
{/each}
</div>
<div class="filter-section">
<h4>価格帯</h4>
{#each filterOptions.price as price}
<label>
<input
type="checkbox"
checked={filterState.prices.includes(price)}
onchange={() => togglePrice(price)}
/>
¥{price}
</label>
{/each}
</div>
<div class="filter-section">
<h4>ブランド</h4>
{#each filterOptions.brand as brand}
<label>
<input
type="checkbox"
checked={filterState.brands.includes(brand)}
onchange={() => toggleBrand(brand)}
/>
{brand}
</label>
{/each}
</div>
<div class="filter-section">
<h4>タグ</h4>
{#each tagOptions as tag}
<button
class="tag"
class:active={filterState.tags.includes(tag)}
onclick={() => toggleTag(tag)}
>
{tag}
</button>
{/each}
</div>
<div class="status">
<p>📊 アクティブフィルター: <strong>{activeFilterCount}</strong>個</p>
{#if queryParams.query}
<div class="query-display">
<p>🔗 本番環境のURLクエリ:</p>
<code class="url-code">?{queryParams.query}</code>
<p>📝 デコード済み(読みやすい形式):</p>
<code class="readable-code">{decodeURIComponent(queryParams.query).split('&').join('\n')}</code>
</div>
{:else}
<p>クエリ文字列: <code>(なし)</code></p>
{/if}
{#if selectedProducts.length > 0}
<div class="selected-filters">
<p><strong>選択中のフィルター:</strong></p>
<ul>
{#each selectedProducts as filter}
<li>{filter}</li>
{/each}
</ul>
</div>
{/if}
{#if activeFilterCount > 0}
<button
onclick={clearAll}
class="clear-btn"
>
すべてクリア
</button>
{/if}
</div>
</div>
<style>
.filter-demo {
padding: 1.5rem;
background: #f9f9f9;
border-radius: 8px;
}
.filter-section {
margin: 1rem 0;
padding: 1rem;
background: white;
border-radius: 4px;
}
.filter-section h4 {
margin: 0 0 0.5rem 0;
color: #333;
}
label {
display: flex;
align-items: center;
margin: 0.25rem 0;
cursor: pointer;
}
label input {
margin-right: 0.5rem;
}
.tag {
margin: 0.25rem;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 20px;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.tag.active {
background: #ff3e00;
color: white;
border-color: #ff3e00;
}
.status {
margin-top: 1rem;
padding: 1rem;
background: #e9f5ff;
border-radius: 4px;
}
.status p {
margin: 0.5rem 0;
}
.status code {
background: #fff;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-family: monospace;
}
.clear-btn {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.clear-btn:hover {
background: #c82333;
}
.selected-filters {
margin: 1rem 0;
padding: 0.5rem;
background: #fff3cd;
border-radius: 4px;
}
.selected-filters ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.selected-filters li {
color: #856404;
}
.query-display {
margin: 1rem 0;
}
.query-display p {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.url-code {
display: block;
background: #f1f1f1;
padding: 0.5rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
margin: 0.25rem 0;
}
.readable-code {
display: block;
background: #e8f5e9;
padding: 0.5rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
margin: 0.25rem 0;
color: #2e7d32;
}
</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
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
Click fold/expand code
パフォーマンスの最適化
Proxyによるリアクティビティは便利ですが、パフォーマンスを考慮した使い方も重要です。
1. 大量データの処理
// ❌ 非効率:各アイテムの変更で全体が再レンダリング
let items = $state(Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.random()
})));
// ✅ 効率的:仮想スクロールやページネーションを使用
import VirtualList from 'svelte-virtual-list';
let items = $state([]);
let visibleItems = $derived(() => {
const start = currentPage * pageSize;
return items.slice(start, start + pageSize);
});
typescript
2. 頻繁な更新の制御
// ❌ 非効率:すべての入力で更新
let searchQuery = $state('');
let results = $derived(async () => {
return await searchAPI(searchQuery); // 毎回APIコール
});
// ✅ 効率的:デバウンスを使用
let searchQuery = $state('');
let debouncedQuery = $state('');
$effect(() => {
const timer = setTimeout(() => {
debouncedQuery = searchQuery;
}, 300);
return () => clearTimeout(timer);
});
let results = $derived(async () => {
if (debouncedQuery) {
return await searchAPI(debouncedQuery);
}
return [];
});
typescript
3. 不要な深いリアクティビティの回避
// ❌ 非効率:静的なデータも追跡される
let config = $state({
api: {
endpoints: {
users: '/api/users',
posts: '/api/posts'
},
timeout: 5000
},
ui: {
theme: 'dark' // これだけが変更される
}
});
// ✅ 効率的:変更される部分だけをリアクティブに
const API_CONFIG = {
endpoints: {
users: '/api/users',
posts: '/api/posts'
},
timeout: 5000
} as const;
let uiConfig = $state({
theme: 'dark'
});
typescript
Proxyの内部動作
Svelte 5の$state
がどのようにProxyを使用しているか、簡略化した実装例です。
// 簡略化されたSvelte 5の内部実装イメージ
function createState<T extends object>(initial: T): T {
const subscribers = new Set<() => void>();
const proxyCache = new WeakMap();
function createProxy(target: any): any {
// キャッシュチェック
if (proxyCache.has(target)) {
return proxyCache.get(target);
}
const proxy = new Proxy(target, {
get(obj, prop) {
const value = obj[prop];
// ネストされたオブジェクトも自動的にProxy化
if (typeof value === 'object' && value !== null) {
return createProxy(value);
}
// 現在のeffectやderivedに依存関係を登録
trackDependency(obj, prop);
return value;
},
set(obj, prop, value) {
const oldValue = obj[prop];
// 値が変更された場合のみ更新
if (oldValue !== value) {
obj[prop] = value;
// 依存しているeffectやderivedを再実行
notifySubscribers();
}
return true;
},
has(obj, prop) {
trackDependency(obj, prop);
return prop in obj;
},
deleteProperty(obj, prop) {
delete obj[prop];
notifySubscribers();
return true;
}
});
proxyCache.set(target, proxy);
return proxy;
}
return createProxy(initial);
}
typescript
まとめ
Svelte 5の$state
は、Proxyの力を活用して以下を実現しています。
機能 | Proxyの活用 | 利点 |
---|---|---|
自然な文法 | オブジェクト・配列の通常操作を検知 | 学習コストが低い |
自動追跡 | getトラップで依存関係を記録 | 明示的な宣言不要 |
深いリアクティビティ | ネストされたオブジェクトも自動Proxy化 | 複雑な状態も簡単管理 |
ビルトインクラス対応 | Map/Set/Date等もProxy化 | 標準APIがそのまま使える |
破壊的メソッド対応 | 配列のpush/splice等も検知 | 自然なコードが書ける |
TypeScript統合 | 型情報を保持したまま動作 | 型安全性を維持 |
特に、RxJSの明示的なリアクティビティと、通常のJavaScriptの簡潔さの「いいとこ取り」を実現し、開発体験を大きく向上させています。