auto-subscription syntax\n```typescript\n// ❌ BROKEN - Can't use $ inside $derived\nlet filtered = $derived($events.filter(e => e.type === 'info'));\n// ^^^^^^^ Error: $ not available in runes mode\n```\n\n**Solution**: Subscribe in `$effect()` → update `$state()` → use in `$derived()`\n```typescript\n// ✅ WORKS - Manual subscription pattern\nimport { type Writable } from 'svelte/store';\n\nlet events = $state\u003cEvent[]>([]);\n\n$effect(() => {\n const unsub = eventsStore.subscribe(value => {\n events = value;\n });\n return unsub;\n});\n\nlet filtered = $derived(events.filter(e => e.type === 'info'));\n```\n\n### ❌ NEVER: Store Factory with Getters\n\n**Problem**: Getters don't establish reactive connections\n```typescript\n// ❌ BROKEN - Getter pattern breaks reactivity\nexport function createSocketStore() {\n const socket = writable\u003cSocket | null>(null);\n return {\n get socket() { return socket; }, // ❌ Not reactive\n connect: () => { /* ... */ }\n };\n}\n```\n\n**Solution**: Export stores directly\n```typescript\n// ✅ WORKS - Direct store exports\nexport function createSocketStore() {\n const socket = writable\u003cSocket | null>(null);\n const isConnected = derived(socket, $s => $s?.connected ?? false);\n\n return {\n socket, // ✅ Direct store reference\n isConnected, // ✅ Direct derived reference\n connect: () => { /* ... */ }\n };\n}\n```\n\n## Recommended Hybrid Pattern\n\n### Global State: Traditional Stores\n\nUse `writable()`/`derived()` for state that needs to survive SSG/SSR:\n\n```typescript\n// stores/globalState.ts\nimport { writable, derived } from 'svelte/store';\n\nexport const user = writable\u003cUser | null>(null);\nexport const theme = writable\u003c'light' | 'dark'>('light');\nexport const isAuthenticated = derived(user, $u => $u !== null);\n```\n\n### Component State: Svelte 5 Runes\n\nUse runes for component-local state and logic:\n\n```typescript\n\u003cscript lang=\"ts\">\nimport { user } from '$lib/stores/globalState';\n\n// Props with runes\nlet {\n initialCount = 0,\n onUpdate = () => {}\n}: {\n initialCount?: number;\n onUpdate?: (count: number) => void;\n} = $props();\n\n// Bridge: Store → Rune State\nlet currentUser = $state\u003cUser | null>(null);\n$effect(() => {\n const unsub = user.subscribe(u => {\n currentUser = u;\n });\n return unsub;\n});\n\n// Component-local state\nlet count = $state(initialCount);\nlet doubled = $derived(count * 2);\n\n// Effects\n$effect(() => {\n if (count > 10) {\n onUpdate(count);\n }\n});\n\nfunction increment() {\n count++;\n}\n\u003c/script>\n\n\u003cbutton onclick={increment}>\n {currentUser?.name ?? 'Guest'}: {count} (×2 = {doubled})\n\u003c/button>\n```\n\n## Complete Bridge Pattern\n\n### Store → Rune → Derived Chain\n\n```typescript\n\u003cscript lang=\"ts\">\nimport { type Writable } from 'svelte/store';\n\n// 1. Import global stores (traditional)\nconst { events: eventsStore, filters: filtersStore } = myGlobalStore;\n\n// 2. Bridge to rune state\nlet events = $state\u003cEvent[]>([]);\nlet activeFilters = $state\u003cstring[]>([]);\n\n$effect(() => {\n const unsubEvents = eventsStore.subscribe(v => { events = v; });\n const unsubFilters = filtersStore.subscribe(v => { activeFilters = v; });\n\n return () => {\n unsubEvents();\n unsubFilters();\n };\n});\n\n// 3. Derived computations (pure runes)\nlet filtered = $derived(\n events.filter(e =>\n activeFilters.length === 0 ||\n activeFilters.includes(e.category)\n )\n);\n\nlet count = $derived(filtered.length);\nlet hasEvents = $derived(count > 0);\n\u003c/script>\n\n{#if hasEvents}\n \u003cp>Found {count} events\u003c/p>\n {#each filtered as event}\n \u003cEventCard {event} />\n {/each}\n{:else}\n \u003cp>No events match filters\u003c/p>\n{/if}\n```\n\n## SSG/SSR Considerations\n\n### Prerender-Safe Patterns\n\n```typescript\n// ✅ Safe for prerendering\nexport const load = async ({ fetch }) => {\n const data = await fetch('/api/data').then(r => r.json());\n return { data };\n};\n```\n\n```svelte\n\u003cscript lang=\"ts\">\nimport { browser } from '$app/environment';\n\nlet { data } = $props();\n\n// ✅ Client-only initialization\n$effect(() => {\n if (browser) {\n // WebSocket, localStorage, etc.\n initializeClientOnlyFeatures();\n }\n});\n\u003c/script>\n```\n\n### Hydration Mismatch Prevention\n\n```typescript\n// ✅ Avoid hydration mismatches\nlet timestamp = $state\u003cnumber | null>(null);\n\n$effect(() => {\n if (browser) {\n timestamp = Date.now(); // Only set on client\n }\n});\n```\n\n```svelte\n\u003c!-- ✅ Conditional rendering for client-only content -->\n{#if browser}\n \u003cLiveClock />\n{:else}\n \u003cp>Loading clock...\u003c/p>\n{/if}\n```\n\n## TypeScript Integration\n\n### Typed Props with Runes\n\n```typescript\n\u003cscript lang=\"ts\">\nimport type { Snippet } from 'svelte';\n\ninterface Props {\n title: string;\n count?: number;\n items: Array\u003c{ id: string; name: string }>;\n onSelect?: (id: string) => void;\n children?: Snippet;\n}\n\nlet {\n title,\n count = 0,\n items,\n onSelect = () => {},\n children\n}: Props = $props();\n\nlet selected = $state\u003cstring | null>(null);\nlet filteredItems = $derived(\n items.filter(item =>\n selected === null || item.id === selected\n )\n);\n\u003c/script>\n\n\u003ch2>{title} ({count})\u003c/h2>\n\n{#each filteredItems as item}\n \u003cbutton onclick={() => onSelect(item.id)}>\n {item.name}\n \u003c/button>\n{/each}\n\n{@render children?.()}\n```\n\n### Typed Store Bridges\n\n```typescript\n\u003cscript lang=\"ts\">\nimport type { Writable, Readable } from 'svelte/store';\n\ninterface StoreShape {\n data: Writable\u003cstring[]>;\n status: Readable\u003c'loading' | 'ready' | 'error'>;\n}\n\nconst stores: StoreShape = getMyStores();\n\nlet data = $state\u003cstring[]>([]);\nlet status = $state\u003c'loading' | 'ready' | 'error'>('loading');\n\n$effect(() => {\n const unsubData = stores.data.subscribe(v => { data = v; });\n const unsubStatus = stores.status.subscribe(v => { status = v; });\n return () => {\n unsubData();\n unsubStatus();\n };\n});\n\nlet isEmpty = $derived(data.length === 0);\nlet isReady = $derived(status === 'ready');\n\u003c/script>\n```\n\n## Common Patterns\n\n### Bindable Component State\n\n```typescript\n\u003cscript lang=\"ts\">\nlet {\n value = $bindable(''),\n disabled = false\n}: {\n value?: string;\n disabled?: boolean;\n} = $props();\n\nlet focused = $state(false);\nlet charCount = $derived(value.length);\nlet isValid = $derived(charCount >= 3 && charCount \u003c= 100);\n\u003c/script>\n\n\u003cinput\n bind:value\n {disabled}\n onfocus={() => { focused = true; }}\n onblur={() => { focused = false; }}\n class:focused\n class:invalid={!isValid}\n/>\n\u003cp>{charCount}/100\u003c/p>\n```\n\n### Form State Management\n\n```typescript\n\u003cscript lang=\"ts\">\ninterface FormData {\n email: string;\n password: string;\n}\n\nlet formData = $state\u003cFormData>({\n email: '',\n password: ''\n});\n\nlet errors = $state\u003cPartial\u003cRecord\u003ckeyof FormData, string>>>({});\n\nlet isValid = $derived(\n formData.email.includes('@') &&\n formData.password.length >= 8\n);\n\nlet canSubmit = $derived(\n isValid && Object.keys(errors).length === 0\n);\n\nfunction validate(field: keyof FormData) {\n if (field === 'email' && !formData.email.includes('@')) {\n errors.email = 'Invalid email';\n } else if (field === 'password' && formData.password.length \u003c 8) {\n errors.password = 'Password too short';\n } else {\n delete errors[field];\n }\n}\n\nasync function handleSubmit() {\n if (!canSubmit) return;\n\n // Submit logic\n const result = await submitForm(formData);\n\n if (result.ok) {\n // Success\n } else {\n errors = result.errors;\n }\n}\n\u003c/script>\n\n\u003cform onsubmit={handleSubmit}>\n \u003cinput\n type=\"email\"\n bind:value={formData.email}\n onblur={() => validate('email')}\n />\n {#if errors.email}\n \u003cspan class=\"error\">{errors.email}\u003c/span>\n {/if}\n\n \u003cinput\n type=\"password\"\n bind:value={formData.password}\n onblur={() => validate('password')}\n />\n {#if errors.password}\n \u003cspan class=\"error\">{errors.password}\u003c/span>\n {/if}\n\n \u003cbutton type=\"submit\" disabled={!canSubmit}>\n Submit\n \u003c/button>\n\u003c/form>\n```\n\n### Debounced Search\n\n```typescript\n\u003cscript lang=\"ts\">\nimport { writable, derived } from 'svelte/store';\n\nconst searchQuery = writable('');\n\n// Traditional derived store with debounce\nconst debouncedQuery = derived(\n searchQuery,\n ($query, set) => {\n const timeout = setTimeout(() => set($query), 300);\n return () => clearTimeout(timeout);\n },\n '' // initial value\n);\n\n// Bridge to rune state\nlet query = $state('');\nlet debouncedValue = $state('');\n\n$effect(() => {\n searchQuery.set(query);\n});\n\n$effect(() => {\n const unsub = debouncedQuery.subscribe(v => {\n debouncedValue = v;\n });\n return unsub;\n});\n\n// Use in derived\nlet results = $derived(\n debouncedValue.length >= 3\n ? performSearch(debouncedValue)\n : []\n);\n\u003c/script>\n\n\u003cinput\n type=\"search\"\n bind:value={query}\n placeholder=\"Search...\"\n/>\n\n{#each results as result}\n \u003cSearchResult {result} />\n{/each}\n```\n\n## Migration Checklist\n\nWhen migrating from Svelte 4 to Svelte 5 with adapter-static:\n\n- [ ] Replace component-level `$:` with `$derived()`\n- [ ] Replace `export let prop` with `let { prop } = $props()`\n- [ ] Keep global stores as `writable()`/`derived()`\n- [ ] Add bridge pattern for store → rune state\n- [ ] Replace `$store` syntax with manual subscription in `$effect()`\n- [ ] Test prerendering with `npm run build`\n- [ ] Verify hydration works correctly\n- [ ] Check for hydration mismatches in console\n- [ ] Ensure client-only code is guarded with `browser` check\n\n## Testing Patterns\n\n### Unit Testing Runes\n\n```typescript\nimport { mount } from 'svelte';\nimport { tick } from 'svelte';\nimport { describe, it, expect } from 'vitest';\nimport Counter from './Counter.svelte';\n\ndescribe('Counter', () => {\n it('increments count', async () => {\n const { component } = mount(Counter, {\n target: document.body,\n props: { initialCount: 0 }\n });\n\n const button = document.querySelector('button');\n button?.click();\n\n await tick();\n\n expect(button?.textContent).toContain('1');\n });\n});\n```\n\n### Testing Store Bridges\n\n```typescript\nimport { get } from 'svelte/store';\nimport { tick } from 'svelte';\nimport { describe, it, expect } from 'vitest';\nimport { createMyStore } from './myStore';\n\ndescribe('Store Bridge', () => {\n it('syncs store to rune state', async () => {\n const store = createMyStore();\n\n store.data.set(['item1', 'item2']);\n\n await tick();\n\n expect(get(store.data)).toEqual(['item1', 'item2']);\n });\n});\n```\n\n## Performance Considerations\n\n### Avoid Unnecessary Reactivity\n\n```typescript\n// ❌ Over-reactive\nlet items = $state([1, 2, 3, 4, 5]);\nlet doubled = $derived(items.map(x => x * 2));\nlet tripled = $derived(items.map(x => x * 3));\nlet quadrupled = $derived(items.map(x => x * 4));\n\n// ✅ Compute only what's needed\nlet items = $state([1, 2, 3, 4, 5]);\nlet transformedItems = $derived(\n mode === 'double' ? items.map(x => x * 2) :\n mode === 'triple' ? items.map(x => x * 3) :\n items.map(x => x * 4)\n);\n```\n\n### Memoize Expensive Computations\n\n```typescript\n// Traditional derived store for expensive computations\nconst expensiveComputation = derived(\n [source1, source2],\n ([$s1, $s2]) => {\n // Expensive calculation\n return complexAlgorithm($s1, $s2);\n }\n);\n\n// Bridge to rune\nlet result = $state(null);\n$effect(() => {\n const unsub = expensiveComputation.subscribe(v => { result = v; });\n return unsub;\n});\n```\n\n## Troubleshooting\n\n### Symptom: State doesn't update after hydration\n\n**Cause**: Runes in module scope with adapter-static\n\n**Fix**: Use traditional `writable()` stores for global state\n\n### Symptom: \"$ is not defined\" error in $derived\n\n**Cause**: Trying to use `$store` syntax in runes mode\n\n**Fix**: Use bridge pattern with `$effect()` subscription\n\n### Symptom: \"Cannot read property of undefined\" after SSG\n\n**Cause**: Store factory with getters instead of direct exports\n\n**Fix**: Export stores directly, not wrapped in getters\n\n### Symptom: Hydration mismatch warnings\n\n**Cause**: Client-only state rendered during SSR\n\n**Fix**: Guard with `browser` check or use `{#if browser}`\n\n## Decision Framework\n\n**Use Traditional Stores When:**\n- State needs to survive SSG/SSR prerendering\n- State is global/shared across components\n- State needs to be serialized/deserialized\n- Working with adapter-static\n\n**Use Runes When:**\n- State is component-local\n- Building reactive UI logic\n- Working with props and component lifecycle\n- Creating derived computations from local state\n\n**Use Bridge Pattern When:**\n- Need to combine global stores with component runes\n- Want derived computations from store values\n- Building complex reactive chains\n\n## Related Skills\n\n- **toolchains-javascript-frameworks-svelte**: Base Svelte patterns\n- **toolchains-typescript-core**: TypeScript integration\n- **toolchains-ui-styling-tailwind**: Styling Svelte components\n- **toolchains-javascript-testing-vitest**: Testing Svelte 5\n\n## References\n\n- [Svelte 5 Runes Documentation](https://svelte.dev/docs/svelte/what-are-runes)\n- [SvelteKit adapter-static](https://kit.svelte.dev/docs/adapter-static)\n- [Svelte Stores](https://svelte.dev/docs/svelte-store)\n","licenseDocuments":[]}}
界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
效率与协作 · SKILL.md

svelte5-runes-static

svelte5-runes-static

【效率与协作】Svelte 5 runes + SvelteKit adapter-static (SSG/SSR) patterns for hydration-safe state, store bridges, and reactivity that survives prerendering

独立 GitHub 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: svelte5-runes-static description: Svelte 5 runes + SvelteKit adapter-static (SSG/SSR) patterns for hydration-safe state, store bridges, and reactivity that survives prerendering user-invocable: false disable-model-invocation: true version: 1.0.0 category: toolchain author: Claude MPM Team license: MIT progressive_disclosure: entry_point: summary: "Hydration-safe Svelte 5 runes patterns for SvelteKit adapter-static (SSG/SSR)" when_to_use: "When using Svelte 5 runes with SvelteKit prerendering/adapter-static and encountering frozen state, hydration mismatches, or store bridge issues" quick_start: "1. Keep global state in writable()/derived() 2. Bridge store → $state in $effect() 3. Use $derived() for computations 4. Guard client-only code with browser" context_limit: 150 tags:

  • svelte
  • svelte5
  • sveltekit
  • runes
  • adapter-static
  • ssr
  • ssg
  • hydration
  • stores
  • reactivity
  • prerendering
  • typescript requires_tools: []

Svelte 5 Runes with adapter-static (SvelteKit)

Overview

Build static-first SvelteKit applications with Svelte 5 runes without breaking hydration. Apply these patterns when using adapter-static (prerendering) and combining global stores with component-local runes.

Related Skills

  • svelte (Svelte 5 runes core patterns)
  • sveltekit (adapters, deployment, SSR/SSG patterns)
  • typescript-core (TypeScript patterns and validation)
  • vitest (unit testing patterns)

Core Expertise

Building static-first Svelte 5 applications using runes mode with proper state management patterns that survive prerendering and hydration.

Critical Compatibility Rules

❌ NEVER: Runes in Module Scope with adapter-static

Problem: Runes don't hydrate properly after static prerendering

// ❌ BROKEN - State becomes frozen after SSG
export function createStore() {
  let state = $state({ count: 0 });
  return {
    get count() { return state.count; },
    increment: () => { state.count++; }
  };
}

Why it fails:

  • adapter-static prerenders components to HTML
  • Runes in module scope don't serialize/deserialize
  • State becomes inert/frozen after hydration
  • Reactivity completely breaks

Solution: Use traditional writable() stores for global state

// ✅ 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)
  };
}

❌ NEVER: $ Auto-subscription Inside $derived

Problem: Runes mode disables $ auto-subscription syntax

// ❌ BROKEN - Can't use $ inside $derived
let filtered = $derived($events.filter(e => e.type === 'info'));
//                      ^^^^^^^ Error: $ not available in runes mode

Solution: Subscribe in $effect() → update $state() → use in $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'));

❌ NEVER: Store Factory with Getters

Problem: Getters don't establish reactive connections

// ❌ BROKEN - Getter pattern breaks reactivity
export function createSocketStore() {
  const socket = writable<Socket | null>(null);
  return {
    get socket() { return socket; }, // ❌ Not reactive
    connect: () => { /* ... */ }
  };
}

Solution: Export stores directly

// ✅ 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: () => { /* ... */ }
  };
}

Recommended Hybrid Pattern

Global State: Traditional Stores

Use writable()/derived() for state that needs to survive SSG/SSR:

// 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);

Component State: Svelte 5 Runes

Use runes for component-local state and logic:

<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>

Complete Bridge Pattern

Store → Rune → Derived Chain

<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 Considerations

Prerender-Safe Patterns

// ✅ 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>

Hydration Mismatch Prevention

// ✅ Avoid hydration mismatches
let timestamp = $state<number | null>(null);

$effect(() => {
  if (browser) {
    timestamp = Date.now(); // Only set on client
  }
});
<!-- ✅ Conditional rendering for client-only content -->
{#if browser}
  <LiveClock />
{:else}
  <p>Loading clock...</p>
{/if}

TypeScript Integration

Typed Props with Runes

<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?.()}

Typed Store Bridges

<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>

Common Patterns

Bindable Component State

<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>

Form State Management

<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>

Debounced Search

<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}

Migration Checklist

When migrating from Svelte 4 to Svelte 5 with adapter-static:

  • Replace component-level $: with $derived()
  • Replace export let prop with let { prop } = $props()
  • Keep global stores as writable()/derived()
  • Add bridge pattern for store → rune state
  • Replace $store syntax with manual subscription in $effect()
  • Test prerendering with npm run build
  • Verify hydration works correctly
  • Check for hydration mismatches in console
  • Ensure client-only code is guarded with browser check

Testing Patterns

Unit Testing 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');
  });
});

Testing Store Bridges

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']);
  });
});

Performance Considerations

Avoid Unnecessary Reactivity

// ❌ 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)
);

Memoize Expensive Computations

// 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;
});

Troubleshooting

Symptom: State doesn't update after hydration

Cause: Runes in module scope with adapter-static

Fix: Use traditional writable() stores for global state

Symptom: "$ is not defined" error in $derived

Cause: Trying to use $store syntax in runes mode

Fix: Use bridge pattern with $effect() subscription

Symptom: "Cannot read property of undefined" after SSG

Cause: Store factory with getters instead of direct exports

Fix: Export stores directly, not wrapped in getters

Symptom: Hydration mismatch warnings

Cause: Client-only state rendered during SSR

Fix: Guard with browser check or use {#if browser}

Decision Framework

Use Traditional Stores When:

  • State needs to survive SSG/SSR prerendering
  • State is global/shared across components
  • State needs to be serialized/deserialized
  • Working with adapter-static

Use Runes When:

  • State is component-local
  • Building reactive UI logic
  • Working with props and component lifecycle
  • Creating derived computations from local state

Use Bridge Pattern When:

  • Need to combine global stores with component runes
  • Want derived computations from store values
  • Building complex reactive chains

Related Skills

  • toolchains-javascript-frameworks-svelte: Base Svelte patterns
  • toolchains-typescript-core: TypeScript integration
  • toolchains-ui-styling-tailwind: Styling Svelte components
  • toolchains-javascript-testing-vitest: Testing Svelte 5

References

我的收藏