svelte5-runes-static について
adapter-static を使う開発者向けに、Svelte 5 runes で静的優先の SvelteKit アプリを構築する方法を解説します。プリレンダリングとハイドレーションに対応する状態管理や、グローバルストアとコンポーネント内 runes の連携を扱います。
主な機能
- 01プリレンダリング対応のグローバル状態管理
- 02ストアと rune のブリッジパターン
- 03ハイドレーション不一致の防止
- 04型付き Props とストア連携
- 05移行とテストのガイド
活用シーン
- 01adapter-static の状態管理方式の選定
- 02ハイドレーション後に状態が停止する問題の調査
- 03共有ストアとコンポーネント内 runes の併用
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill svelte5-runes-staticインストール後、Claude Code で/svelte5-runes-staticを入力して読み込まれたか確認します。
同梱ファイルmetadata.json
詳細
adapter-static を使用した Svelte 5 Runes(SvelteKit)
概要
ハイドレーションを損なうことなく、Svelte 5 の runes を使用して静的ファーストの SvelteKit アプリケーションを構築します。adapter-static(プリレンダリング)を使用し、グローバルストアとコンポーネントローカルの runes を組み合わせる際に、これらのパターンを適用してください。
関連スキル
svelte(Svelte 5 runes のコアパターン)sveltekit(アダプター、デプロイ、SSR/SSG のパターン)typescript-core(TypeScript のパターンとバリデーション)vitest(ユニットテストのパターン)
コアとなる専門知識
プリレンダリングとハイドレーションを経ても適切に機能する状態管理パターンを用いて、runes モードで静的ファーストの Svelte 5 アプリケーションを構築します。
重要な互換性ルール
❌ 禁止:adapter-static でのモジュールスコープの runes
問題:静的プリレンダリング後、runes が正しくハイドレーションされません
// ❌ BROKEN - State becomes frozen after SSG
export function createStore() {
let state = $state({ count: 0 });
return {
get count() { return state.count; },
increment: () => { state.count++; }
};
}
失敗する理由:
adapter-staticはコンポーネントを HTML にプリレンダリングします- モジュールスコープの runes はシリアライズ/デシリアライズされません
- ハイドレーション後、状態が不活性/固定状態になります
- リアクティビティが完全に失われます
解決策:グローバル状態には従来の writable() ストアを使用します
// ✅ WORKS - Traditional stores hydrate correctly
import { writable } from 'svelte/store';
export function createStore() {
const count = writable(0);
return {
count,
increment: () => count.update(n => n + 1)
};
}
❌ 禁止:$derived 内での $ 自動サブスクリプション
問題:runes モードでは $ による自動サブスクリプション構文が無効になります
// ❌ BROKEN - Can't use $ inside $derived
let filtered = $derived($events.filter(e => e.type === 'info'));
// ^^^^^^^ Error: $ not available in runes mode
解決策:$effect() 内でサブスクライブ → $state() を更新 → $derived() で使用します
// ✅ WORKS - Manual subscription pattern
import { type Writable } from 'svelte/store';
let events = $state<Event[]>([]);
$effect(() => {
const unsub = eventsStore.subscribe(value => {
events = value;
});
return unsub;
});
let filtered = $derived(events.filter(e => e.type === 'info'));
❌ 禁止:ゲッターを使用したストアファクトリー
問題:ゲッターではリアクティブな接続が確立されません
// ❌ BROKEN - Getter pattern breaks reactivity
export function createSocketStore() {
const socket = writable<Socket | null>(null);
return {
get socket() { return socket; }, // ❌ Not reactive
connect: () => { /* ... */ }
};
}
解決策:ストアを直接エクスポートします
// ✅ WORKS - Direct store exports
export function createSocketStore() {
const socket = writable<Socket | null>(null);
const isConnected = derived(socket, $s => $s?.connected ?? false);
return {
socket, // ✅ Direct store reference
isConnected, // ✅ Direct derived reference
connect: () => { /* ... */ }
};
}
推奨されるハイブリッドパターン
グローバル状態:従来のストア
SSG/SSR を経ても維持する必要がある状態には、writable()/derived() を使用します:
// stores/globalState.ts
import { writable, derived } from 'svelte/store';
export const user = writable<User | null>(null);
export const theme = writable<'light' | 'dark'>('light');
export const isAuthenticated = derived(user, $u => $u !== null);
コンポーネントの状態:Svelte 5 Runes
コンポーネントローカルの状態やロジックには runes を使用します:
<script lang="ts">
import { user } from '$lib/stores/globalState';
// Props with runes
let {
initialCount = 0,
onUpdate = () => {}
}: {
initialCount?: number;
onUpdate?: (count: number) => void;
} = $props();
// Bridge: Store → Rune State
let currentUser = $state<User | null>(null);
$effect(() => {
const unsub = user.subscribe(u => {
currentUser = u;
});
return unsub;
});
// Component-local state
let count = $state(initialCount);
let doubled = $derived(count * 2);
// Effects
$effect(() => {
if (count > 10) {
onUpdate(count);
}
});
function increment() {
count++;
}
</script>
<button onclick={increment}>
{currentUser?.name ?? 'Guest'}: {count} (×2 = {doubled})
</button>
完全なブリッジパターン
ストア → Rune → Derived の連鎖
<script lang="ts">
import { type Writable } from 'svelte/store';
// 1. Import global stores (traditional)
const { events: eventsStore, filters: filtersStore } = myGlobalStore;
// 2. Bridge to rune state
let events = $state<Event[]>([]);
let activeFilters = $state<string[]>([]);
$effect(() => {
const unsubEvents = eventsStore.subscribe(v => { events = v; });
const unsubFilters = filtersStore.subscribe(v => { activeFilters = v; });
return () => {
unsubEvents();
unsubFilters();
};
});
// 3. Derived computations (pure runes)
let filtered = $derived(
events.filter(e =>
activeFilters.length === 0 ||
activeFilters.includes(e.category)
)
);
let count = $derived(filtered.length);
let hasEvents = $derived(count > 0);
</script>
{#if hasEvents}
<p>Found {count} events</p>
{#each filtered as event}
<EventCard {event} />
{/each}
{:else}
<p>No events match filters</p>
{/if}
SSG/SSR に関する考慮事項
プリレンダリングに安全なパターン
// ✅ Safe for prerendering
export const load = async ({ fetch }) => {
const data = await fetch('/api/data').then(r => r.json());
return { data };
};
<script lang="ts">
import { browser } from '$app/environment';
let { data } = $props();
// ✅ Client-only initialization
$effect(() => {
if (browser) {
// WebSocket, localStorage, etc.
initializeClientOnlyFeatures();
}
});
</script>
ハイドレーションの不一致を防止する
// ✅ Avoid hydration mismatches
let timestamp = $state<number | null>(null);
$effect(() => {
if (browser) {
timestamp = Date.now(); // Only set on client
}
});
{#if browser}
<LiveClock />
{:else}
<p>Loading clock...</p>
{/if}
TypeScript との統合
Runes を使用した型付き Props
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
count?: number;
items: Array<{ id: string; name: string }>;
onSelect?: (id: string) => void;
children?: Snippet;
}
let {
title,
count = 0,
items,
onSelect = () => {},
children
}: Props = $props();
let selected = $state<string | null>(null);
let filteredItems = $derived(
items.filter(item =>
selected === null || item.id === selected
)
);
</script>
<h2>{title} ({count})</h2>
{#each filteredItems as item}
<button onclick={() => onSelect(item.id)}>
{item.name}
</button>
{/each}
{@render children?.()}
型付きストアブリッジ
<script lang="ts">
import type { Writable, Readable } from 'svelte/store';
interface StoreShape {
data: Writable<string[]>;
status: Readable<'loading' | 'ready' | 'error'>;
}
const stores: StoreShape = getMyStores();
let data = $state<string[]>([]);
let status = $state<'loading' | 'ready' | 'error'>('loading');
$effect(() => {
const unsubData = stores.data.subscribe(v => { data = v; });
const unsubStatus = stores.status.subscribe(v => { status = v; });
return () => {
unsubData();
unsubStatus();
};
});
let isEmpty = $derived(data.length === 0);
let isReady = $derived(status === 'ready');
</script>
一般的なパターン
バインド可能なコンポーネント状態
<script lang="ts">
let {
value = $bindable(''),
disabled = false
}: {
value?: string;
disabled?: boolean;
} = $props();
let focused = $state(false);
let charCount = $derived(value.length);
let isValid = $derived(charCount >= 3 && charCount <= 100);
</script>
<input
bind:value
{disabled}
onfocus={() => { focused = true; }}
onblur={() => { focused = false; }}
class:focused
class:invalid={!isValid}
/>
<p>{charCount}/100</p>
フォームの状態管理
<script lang="ts">
interface FormData {
email: string;
password: string;
}
let formData = $state<FormData>({
email: '',
password: ''
});
let errors = $state<Partial<Record<keyof FormData, string>>>({});
let isValid = $derived(
formData.email.includes('@') &&
formData.password.length >= 8
);
let canSubmit = $derived(
isValid && Object.keys(errors).length === 0
);
function validate(field: keyof FormData) {
if (field === 'email' && !formData.email.includes('@')) {
errors.email = 'Invalid email';
} else if (field === 'password' && formData.password.length < 8) {
errors.password = 'Password too short';
} else {
delete errors[field];
}
}
async function handleSubmit() {
if (!canSubmit) return;
// Submit logic
const result = await submitForm(formData);
if (result.ok) {
// Success
} else {
errors = result.errors;
}
}
</script>
<form onsubmit={handleSubmit}>
<input
type="email"
bind:value={formData.email}
onblur={() => validate('email')}
/>
{#if errors.email}
<span class="error">{errors.email}</span>
{/if}
<input
type="password"
bind:value={formData.password}
onblur={() => validate('password')}
/>
{#if errors.password}
<span class="error">{errors.password}</span>
{/if}
<button type="submit" disabled={!canSubmit}>
Submit
</button>
</form>
デバウンス付き検索
<script lang="ts">
import { writable, derived } from 'svelte/store';
const searchQuery = writable('');
// Traditional derived store with debounce
const debouncedQuery = derived(
searchQuery,
($query, set) => {
const timeout = setTimeout(() => set($query), 300);
return () => clearTimeout(timeout);
},
'' // initial value
);
// Bridge to rune state
let query = $state('');
let debouncedValue = $state('');
$effect(() => {
searchQuery.set(query);
});
$effect(() => {
const unsub = debouncedQuery.subscribe(v => {
debouncedValue = v;
});
return unsub;
});
// Use in derived
let results = $derived(
debouncedValue.length >= 3
? performSearch(debouncedValue)
: []
);
</script>
<input
type="search"
bind:value={query}
placeholder="Search..."
/>
{#each results as result}
<SearchResult {result} />
{/each}
移行チェックリスト
Svelte 4 から adapter-static を使用する Svelte 5 に移行する場合:
- コンポーネントレベルの
$:を$derived()に置き換える -
export let propをlet { prop } = $props()に置き換える - グローバルストアは
writable()/derived()のままにする - ストア → rune の状態ブリッジパターンを追加する
-
$store構文を$effect()内の手動サブスクリプションに置き換える -
npm run buildでプリレンダリングをテストする - ハイドレーションが正しく機能することを確認する
- コンソールでハイドレーションの不一致がないか確認する
-
browserチェックでクライアント専用コードをガードする
テストパターン
Runes のユニットテスト
import { mount } from 'svelte';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('increments count', async () => {
const { component } = mount(Counter, {
target: document.body,
props: { initialCount: 0 }
});
const button = document.querySelector('button');
button?.click();
await tick();
expect(button?.textContent).toContain('1');
});
});
ストアブリッジのテスト
import { get } from 'svelte/store';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import { createMyStore } from './myStore';
describe('Store Bridge', () => {
it('syncs store to rune state', async () => {
const store = createMyStore();
store.data.set(['item1', 'item2']);
await tick();
expect(get(store.data)).toEqual(['item1', 'item2']);
});
});
パフォーマンスに関する考慮事項
不要なリアクティビティを避ける
// ❌ Over-reactive
let items = $state([1, 2, 3, 4, 5]);
let doubled = $derived(items.map(x => x * 2));
let tripled = $derived(items.map(x => x * 3));
let quadrupled = $derived(items.map(x => x * 4));
// ✅ Compute only what's needed
let items = $state([1, 2, 3, 4, 5]);
let transformedItems = $derived(
mode === 'double' ? items.map(x => x * 2) :
mode === 'triple' ? items.map(x => x * 3) :
items.map(x => x * 4)
);
コストの高い計算をメモ化する
// Traditional derived store for expensive computations
const expensiveComputation = derived(
[source1, source2],
([$s1, $s2]) => {
// Expensive calculation
return complexAlgorithm($s1, $s2);
}
);
// Bridge to rune
let result = $state(null);
$effect(() => {
const unsub = expensiveComputation.subscribe(v => { result = v; });
return unsub;
});
トラブルシューティング
症状:ハイドレーション後に状態が更新されない
原因:adapter-static でのモジュールスコープの runes
解決策:グローバル状態には従来の writable() ストアを使用します
症状:$derived 内で「$ is not defined」エラーが発生する
原因:runes モードで $store 構文を使用しようとしている
解決策:$effect() のサブスクリプションを使ったブリッジパターンを使用します
症状:SSG 後に「Cannot read property of undefined」が発生する
原因:直接エクスポートではなく、ゲッターを使用したストアファクトリー
解決策:ゲッターでラップせず、ストアを直接エクスポートします
症状:ハイドレーションの不一致に関する警告が表示される
原因:SSR 中にクライアント専用の状態がレンダリングされている
解決策:browser チェックでガードするか、{#if browser} を使用します
選択の指針
従来のストアを使用する場合:
- 状態をSSG/SSRのプリレンダリング後も保持する必要がある場合
- コンポーネント間で状態をグローバルに共有する場合
- 状態をシリアライズ/デシリアライズする必要がある場合
- adapter-static を使用する場合
ルーンを使用する場合:
- 状態がコンポーネント内に限定される場合
- リアクティブなUIロジックを構築する場合
- プロパティやコンポーネントのライフサイクルを扱う場合
- ローカル状態から派生する計算を作成する場合
ブリッジパターンを使用する場合:
- グローバルストアとコンポーネントのルーンを組み合わせる必要がある場合
- ストアの値から派生する計算を行いたい場合
- 複雑なリアクティブチェーンを構築する場合
関連スキル
- toolchains-javascript-frameworks-svelte:Svelteの基本的なパターン
- toolchains-typescript-core:TypeScriptとの統合
- toolchains-ui-styling-tailwind:Svelteコンポーネントのスタイリング
- toolchains-javascript-testing-vitest:Svelte 5のテスト