Svelte 5 Runes skill for Codex

Derive state, reserve effects for the outside world. Codex writes Svelte 5 components that compute with $derived instead of effects, keep $effect for timers, DOM and network work with cleanup, and replace slots and dispatched events with snippets and callback props. It moves shared state into .svelte.ts classes, knows where module state leaks between SSR requests, and finishes the manual half of a Svelte 4 migration that the sv migrate tool leaves behind.

  • Frontend
  • $svelte-5-runes
  • 175 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- svelte-5-runes

Installs to ~/.agents/skills/svelte-5-runes/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $svelte-5-runes, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and migrates Svelte 5 components with runes - $state and $state.raw, $derived and $derived.by, $effect with cleanup, $props with $bindable, snippets and render tags, event attributes, shared state in .svelte.ts modules, and the svelte/reactivity classes. Use when building Svelte 5 components, converting Svelte 4 code (export let, $:, stores, on:click, slots, createEventDispatcher), or debugging effects that loop, derived values that do not update, or state lost across module boundaries.

Use it when

  • Writing new Svelte 5 components or shared reactive modules
  • Converting export let, $:, slots and on:event code to runes
  • Effects loop, fire too often, or derived values go stale
  • Choosing between stores and .svelte.ts state

Use something else when

  • The work is routing, load functions, form actions or deployment: use sveltekit

How to check the result

svelte-check reports zero warnings, no svelte/legacy imports remain unless documented, and the browser console shows no effect depth or ownership warnings.

The mistake it prevents

Using $effect to copy one piece of state into another, which causes extra renders and loops where a $derived would have been correct.

~/.agents/skills/svelte-5-runes/SKILL.md

--- name: svelte-5-runes description: Writes and migrates Svelte 5 components with runes - $state and $state.raw, $derived and $derived.by, $effect with cleanup, $props with $bindable,… license: MIT ---

Done well means: state is declared with $state, everything computable is $derived, $effect is used only to sync with the outside world (DOM APIs, timers, network, logging) and always cleans up, and components compile in runes mode with zero warnings from svelte-check.

Inspect first

  1. Read AGENTS.md and package.json: svelte must be 5.x. Note @sveltejs/kit (routing and data loading live in $sveltekit).
  2. Check svelte.config.js for compilerOptions.runes (forces runes mode) or experimental.async (enables await in components).
  3. A component is in runes mode as soon as it uses any rune. Legacy syntax (export let, $:) in the same file is then a compile error, so convert a file completely.

The runes

<script lang="ts">
  import type { Snippet } from "svelte";

  interface Props {
    items: { id: string; label: string; price: number }[];
    selected?: string;               // bindable from the parent
    onchoose?: (id: string) => void; // callback prop replaces createEventDispatcher
    empty?: Snippet;                 // replaces a named slot
    row: Snippet<[{ id: string; label: string; price: number }]>;
  }
  let { items, selected = $bindable(), onchoose, empty, row }: Props = $props();

  let query = $state("");
  let filtered = $derived(items.filter((i) => i.label.toLowerCase().includes(query.toLowerCase())));
  let total = $derived.by(() => {
    let sum = 0;
    for (const i of filtered) sum += i.price;
    return sum;
  });

  $effect(() => {
    const id = setTimeout(() => console.debug("filtered", filtered.length), 300);
    return () => clearTimeout(id); // runs before the next run and on destroy
  });
</script>

<input bind:value={query} placeholder="Filter" />
{#each filtered as item (item.id)}
  <button class:active={item.id === selected} onclick={() => { selected = item.id; onchoose?.(item.id); }}>
    {@render row(item)}
  </button>
{:else}
  {@render empty?.()}
{/each}
<p>Total: {total}</p>

Parent usage:

<List {items} bind:selected onchoose={track}>
  {#snippet row(item)}<span>{item.label}</span>{/snippet}
  {#snippet empty()}<p>No matches</p>{/snippet}
</List>

Content placed directly between component tags becomes the children snippet prop.

Choosing the rune

NeedUseNot
Mutable local value, object or array (deeply reactive)$state
Large data you only replace, never mutate (API payloads)$state.raw$state (proxies every node)
Value computed from other state$derived(expr) / $derived.by(fn)$effect that assigns state
Plain copy of state to pass to a library or structuredClone$state.snapshot(x)passing the proxy
React to state by touching the DOM, a timer, a socket$effect
Measure layout before paint$effect.pre
Debug what changed$inspect(x) (dev only)$effect + console.log
Two-way bound prop$bindable()mutating a non-bindable prop
Unique id for label/input pairs$props.id()counters

Derived values can be reassigned locally since Svelte 5.25 (for optimistic UI); the next change in dependencies recomputes them.

Effects: rules

  • Dependencies are whatever the effect reads synchronously. Values read after an await or inside setTimeout are not tracked. Read them first, then await.
  • An effect that writes state it also reads loops (effect_update_depth_exceeded). If you must read without tracking, use untrack(() => value).
  • Effects do not run during SSR. Code that must run on the server cannot live in one.
  • Prefer event handlers for user-driven work: “when the user clicks save, POST” belongs in onclick, not in an effect watching a saving flag.
  • Return a cleanup function for anything you start.

Shared state across components

Runes work in .svelte.ts / .svelte.js files. Export an object or class, never a reassigned primitive:

// lib/cart.svelte.ts
export class Cart {
  lines = $state<{ sku: string; qty: number }[]>([]);
  count = $derived(this.lines.reduce((n, l) => n + l.qty, 0));
  add(sku: string) {
    const l = this.lines.find((x) => x.sku === sku);
    if (l) l.qty++; else this.lines.push({ sku, qty: 1 });
  }
}
export const cart = new Cart(); // client-only apps; see SSR note below
  • export let count = $state(0) cannot be reassigned by importers; export an object (export const counter = $state({ value: 0 })) or getter functions.
  • In SvelteKit, module-level state is shared by every request on the server. For per-user state, create it in a component and pass it with setContext/getContext.
  • Use SvelteMap, SvelteSet, SvelteURL, SvelteDate from svelte/reactivity; plain Map/Set inside $state are not made reactive.
  • Destructuring a $state object (let { a } = obj) copies values; reference obj.a.

Migrating from Svelte 4

- [ ] Commit; run the test suite and svelte-check for a baseline
- [ ] npx sv migrate svelte-5            (needs network; rewrites syntax, adds TODOs)
- [ ] Search for "@migration" comments and run_in_legacy_mode markers; fix each by hand
- [ ] Replace createEventDispatcher with callback props; update parents (on:x -> onx)
- [ ] Replace $: side effects with $effect only where truly side effects; others -> $derived
- [ ] Convert slots to snippets where the migrator left <slot>
- [ ] Decide store usage: stores still work; move to .svelte.ts classes when touching them
- [ ] svelte-check, tests, manual pass on interactive pages

The migration script handles one component at a time well but cannot know whether a $: block is a derivation or a side effect; it often emits run(...) from svelte/legacy. Treat every svelte/legacy import as a TODO. For the full Svelte 4 to 5 mapping (events, modifiers, slots, lifecycle, component API), read references/migration-map.md when converting by hand.

Gotchas

  • Event modifiers are gone: on:click|preventDefault becomes onclick={(e) => { e.preventDefault(); ... }}.
  • bind:this on a component gives its exports, not $$props; components are functions, not classes. new Component(...) becomes mount(Component, { target, props }).
  • $state objects are proxies: === against the original object fails, and console.log shows a Proxy. Use $state.snapshot or $inspect.
  • {#each} without a key reuses DOM by index; add (item.id) for lists that reorder.
  • Props are read-only unless $bindable; mutating a prop object warns (ownership_invalid_mutation). Emit a callback or make it bindable.
  • onMount still works; beforeUpdate/afterUpdate do not in runes mode. Use $effect.pre and $effect.

Verification

npx svelte-check --threshold warning   # zero runes or a11y warnings
npx vitest run                         # @testing-library/svelte supports Svelte 5
  • In tests, wrap state changes made outside components in flushSync() before asserting.
  • Open the page with the browser console visible: no effect_update_depth_exceeded, state_unsafe_mutation or ownership warnings.

Output

Report components converted or added, any prop or event API change parents must adopt (on:x to onx, slot to snippet), remaining svelte/legacy usages, and test results.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Svelte 5 Runes"
  short_description: "Derive state, reserve effects for the outside world"
  default_prompt: "$svelte-5-runes Migrate this Svelte 4 component library to Svelte 5 runes"
references/migration-map.md83 lines, 4.1 KB
# Svelte 4 to Svelte 5 mapping

Read this when converting components by hand or reviewing what `sv migrate svelte-5`
produced. Left column is Svelte 4, right column is the runes-mode equivalent.

## Script

| Svelte 4 | Svelte 5 |
|---|---|
| `let count = 0` (top level, reactive) | `let count = $state(0)` |
| `export let name` | `let { name } = $props()` |
| `export let size = 'md'` | `let { size = 'md' } = $props()` |
| `export { klass as class }` | `let { class: klass } = $props()` |
| `$$props`, `$$restProps` | `let { a, b, ...rest } = $props()` |
| `$: doubled = count * 2` | `let doubled = $derived(count * 2)` |
| `$: { multi; line; derivation }` | `let x = $derived.by(() => { ...; return v })` |
| `$: if (open) document.body.classList.add('lock')` | `$effect(() => { if (open) { ...; return () => ... } })` |
| `$: console.log(value)` | `$inspect(value)` |
| `createEventDispatcher` + `dispatch('save', d)` | callback prop `onsave?.(d)` |
| `beforeUpdate` | `$effect.pre` |
| `afterUpdate` | `$effect` |
| `onMount`, `onDestroy` | unchanged (or `$effect` with cleanup) |
| `export function focus()` | unchanged; access via `bind:this` |
| `<svelte:options accessors />` | removed; expose via exports |
| `<svelte:options immutable />` | removed; use `$state.raw` for replace-only data |

## Template

| Svelte 4 | Svelte 5 |
|---|---|
| `on:click={handler}` | `onclick={handler}` |
| `on:click` (forwarding) | accept `onclick` prop and spread or pass it |
| `on:click|preventDefault={h}` | `onclick={(e) => { e.preventDefault(); h(e) }}` |
| `on:click|once` | wrap handler with a flag, or a small `once()` helper |
| `on:click|capture` | `onclickcapture={h}` |
| multiple `on:click` on one element | one `onclick` calling both functions |
| `<slot />` | `{@render children?.()}` with `children` from `$props()` |
| `<slot name="header" />` | `{@render header?.()}` |
| `<slot item={x} />` (slot props) | `{@render row(x)}`, parent writes `{#snippet row(x)}` |
| `<div slot="header">` in parent | `{#snippet header()}<div>...</div>{/snippet}` |
| `let:item` | snippet parameter |
| `$$slots.header` | `if (header)` on the snippet prop |
| `<svelte:component this={C} />` | `<C />` (components are dynamic by default) |
| `<svelte:fragment slot="x">` | `{#snippet x()}...{/snippet}` |

## Component API (imperative)

| Svelte 4 | Svelte 5 |
|---|---|
| `new App({ target, props })` | `mount(App, { target, props })` |
| `app.$destroy()` | `unmount(app)` |
| `app.$set({ x })` | pass a `$state` object as props and mutate it |
| `app.$on('event', cb)` | callback props passed to `mount` (the `events` option exists but is a legacy escape hatch) |
| `render(App, { props })` from `svelte/server` | `render(App, { props })` returns `{ head, body }` |
| type `SvelteComponent` | type `Component<Props>` |
| `ComponentProps<typeof X>` | unchanged, works with function components |

## Stores

Stores (`writable`, `readable`, `derived`, `$store` auto-subscription) still work in
Svelte 5 and in runes-mode components. Migrate them only when it simplifies code:

| Store pattern | Runes alternative |
|---|---|
| `writable(0)` in a module | object or class with `$state` in a `.svelte.ts` file |
| `derived(a, $a => ...)` | `$derived` field on the class |
| custom store with `subscribe` | class with `$state` fields and methods |
| store consumed outside Svelte (plain TS) | keep the store, or use `createSubscriber` from `svelte/reactivity` |

On the server (SvelteKit), both module-level stores and module-level `$state` are shared
between requests. Use context for per-request state in either version.

## Things the migration tool leaves for you

- `run(() => ...)` from `svelte/legacy`: decide `$derived` or `$effect`.
- `createBubbler()` / `handlers()` from `svelte/legacy`: event forwarding; replace with
  callback props.
- `<!-- @migration-task -->` comments: usually slots used in ways snippets cannot express
  automatically, or `$$props` access patterns.
- Mutations of props: become `$bindable` on the child and `bind:` in the parent, or a
  callback.
- CSS `:global` usage and class directives are unchanged; check component CSS only if
  you renamed props like `class`.

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc