Skip to content

Worked review 1: a todo list with filters, undo, and a stats panel

The prompt was: “Build a todo list with add/rename/toggle, an undo history, a filter bar, a completed-count badge, and a stats panel showing how many are active.” The result below is the kind of first draft an AI assistant hands back for a small Svelte 5 feature — every file uses $props()/$state/$derived somewhere, so the whole project (forced into runes mode by this course’s own vite.config.ts, same as every AI-generated project since Svelte 5.0) treats every file as runes-mode code. Read it in this module’s reading order: runes vs legacy mode first, then who owns each piece of state, then props/bindings/snippets, then events and the DOM, then keys. It has 6 real bugs, from four different categories, and two of them are hard compile errors svelte-check catches immediately — the other four compile clean and only misbehave.

// src/lib/todos/types.ts — as generated
export type Todo = { id: string; text: string; done: boolean };
export type Filter = 'all' | 'active' | 'completed';
<!-- src/lib/todos/TodoStats.svelte — as generated -->
<script lang="ts">
import type { Todo } from './types';
export let todos: Todo[];
$: activeCount = todos.filter((t) => !t.done).length;
$: percentDone = todos.length === 0 ? 0 : Math.round(((todos.length - activeCount) / todos.length) * 100);
</script>
<dl>
<dt>Active</dt><dd>{activeCount}</dd>
<dt>Done</dt><dd>{percentDone}%</dd>
</dl>
<!-- src/lib/todos/CompletedBadge.svelte — as generated -->
<script lang="ts">
import type { Todo } from './types';
let { todos }: { todos: Todo[] } = $props();
let doneCount = $state(0);
$effect(() => {
doneCount = todos.filter((t) => t.done).length;
});
</script>
<p>{doneCount} done</p>
<!-- src/lib/todos/EditableLabel.svelte — as generated -->
<script lang="ts">
let { text, onSave }: { text: string; onSave: (v: string) => void } = $props();
let draft = $state(text); // mirrors the prop once, at mount
</script>
<input bind:value={draft} onblur={() => onSave(draft)} />
<!-- src/lib/todos/TodoItem.svelte — as generated -->
<script lang="ts">
import type { Todo } from './types';
import EditableLabel from './EditableLabel.svelte';
let {
todo,
onToggle,
onRename,
}: { todo: Todo; onToggle: (id: string) => void; onRename: (id: string, text: string) => void } = $props();
let selected = $state(false);
</script>
<li>
<input type="checkbox" bind:checked={selected} />
<input type="checkbox" checked={todo.done} onchange={() => onToggle(todo.id)} />
<EditableLabel text={todo.text} onSave={(v) => onRename(todo.id, v)} />
</li>
<!-- src/lib/todos/TodoList.svelte — as generated -->
<script lang="ts">
import type { Todo } from './types';
import TodoItem from './TodoItem.svelte';
let {
todos,
onToggle,
onRename,
}: { todos: Todo[]; onToggle: (id: string) => void; onRename: (id: string, text: string) => void } = $props();
</script>
<ul>
{#each todos as todo}
<TodoItem {todo} {onToggle} {onRename} />
{/each}
</ul>
<!-- src/lib/todos/TodoApp.svelte — as generated -->
<script lang="ts">
import type { Filter, Todo } from './types';
import TodoList from './TodoList.svelte';
import TodoStats from './TodoStats.svelte';
import CompletedBadge from './CompletedBadge.svelte';
let nextId = 1;
let todos = $state<Todo[]>([]);
let filter = $state<Filter>('all');
let history = $state<Todo[][]>([]);
function pushHistory() {
history.push(todos.map((t) => ({ ...t })));
}
function addTodo(text: string) {
pushHistory();
todos.push({ id: String(nextId++), text, done: false });
}
function toggleTodo(id: string) {
pushHistory();
const t = todos.find((t) => t.id === id);
if (t) t.done = !t.done;
}
function renameTodo(id: string, text: string) {
pushHistory();
const t = todos.find((t) => t.id === id);
if (t) t.text = text;
}
function undo() {
const prev = history.pop();
if (prev) todos = prev;
}
let visible = $derived(
todos.filter((t) => (filter === 'active' ? !t.done : filter === 'completed' ? t.done : true)),
);
</script>
{#snippet emptyState()}
<p>No todos match this filter.</p>
{/snippet}
<div>
<button onclick={() => addTodo(`Task ${nextId}`)}>Add</button>
<button onclick={undo} disabled={history.length === 0}>Undo</button>
<div role="group">
{#each ['all', 'active', 'completed'] as const as f}
<button aria-pressed={filter === f} on:click={() => (filter = f)}>{f}</button>
{/each}
</div>
<TodoList todos={visible} onToggle={toggleTodo} onRename={renameTodo} />
<TodoStats {todos} />
<CompletedBadge {todos} />
</div>

Runes vs legacy mode: export let and $: mixed into a runes file

Section titled “Runes vs legacy mode: export let and $: mixed into a runes file”

TodoStats mixes Svelte 4’s export let/$: reactivity primitives into a project that is in runes mode everywhere (any rune anywhere in a file — or, as here, a project-wide runes: true compiler option — puts that file in runes mode). This is the single most common shape of AI-generated Svelte bug, and it is not a soft warning:

🐛 Spot the bug

export let todos: Todo[];
$: activeCount = todos.filter((t) => !t.done).length;
Show the answer

Both lines are hard compile errors the instant the file is in runes mode — not “unexpected but tolerated,” a real, blocking svelte-check error:

Error: Cannot use `export let` in runes mode — use `$props()` instead
https://svelte.dev/e/legacy_export_invalid

Removing just the export let line and re-checking the file shows the second legacy primitive is its own, independent hard error too — the compiler does not stop at “one legacy pattern is enough to flag,” each one is checked and rejected on its own:

Error: `$:` is not allowed in runes mode, use `$derived` or `$effect` instead
https://svelte.dev/e/legacy_reactive_statement_invalid

This exact pair is already proven end-to-end, with the real thrown text from compile() itself, in Events & Attributes — this SpotTheBug reproduces it against this feature’s own file. The fix is $props() and $derived, shown below.

Who owns this state: an $effect writing a value $derived should own

Section titled “Who owns this state: an $effect writing a value $derived should own”

CompletedBadge computes doneCount — a value entirely derivable from todos — by writing it into a separate $state variable from inside an $effect.

🐛 Spot the bug

let doneCount = $state(0);
$effect(() => {
doneCount = todos.filter((t) => t.done).length;
});
Show the answer

This compiles clean and looks correct on every render you’d check by eye. The $effect Rune lesson proves the concrete cost with a running test: the mirrored value is genuinely, measurably stale for a window between the triggering write and the next flush, in a way $derived never is. Reproduced here against this exact pattern:

✓ CompletedBadge: re-syncs doneCount through a whole extra $effect commit on every todos change
✓ bare-state proof: mirroring a derived value via $effect lags one flush behind $derived
Test Files 1 passed (1)
Tests 2 passed (2)

The fix is deleting the $effect and the separate $state variable entirely: let doneCount = $derived(todos.filter((t) => t.done).length); recomputes the instant it is read, with no lag and no second place for the value to live.

Who owns this state: mirroring a prop into $state

Section titled “Who owns this state: mirroring a prop into $state”

EditableLabel copies the text prop into a local draft at mount, to give the input something to edit locally before saving.

🐛 Spot the bug

let { text, onSave } = $props();
let draft = $state(text); // mirrors the prop once, at mount
Show the answer

$state(text) reads text exactly once — at the moment this line runs — and never again. svelte-check even calls this out on its own, unprompted:

Warn: This reference only captures the initial value of `text`. Did you mean to reference it inside a closure instead?
https://svelte.dev/e/state_referenced_locally

But the warning is easy to dismiss as noise, so prove the actual behavior: if text changes from OUTSIDE this component later — an undo restoring a previous version of this exact todo, the most realistic trigger here — draft does not follow it.

✓ EditableLabelBad: does not resync draft when the text prop changes from outside (e.g. undo)
Test Files 1 passed (1)
Tests 1 passed (1)

The fix keeps the local edit-buffer (a real design need — you can’t safely bind directly to an immutable prop while typing) but resyncs it whenever the upstream value changes, and silences the warning honestly with untrack (shown in the corrected code below) rather than ignoring it.

Props, bindings, and snippets: a {#snippet} that is never {@render}ed

Section titled “Props, bindings, and snippets: a {#snippet} that is never {@render}ed”

TodoApp defines an emptyState snippet for when the filtered list is empty, but never actually renders it anywhere.

🐛 Spot the bug

{#snippet emptyState()}
<p>No todos match this filter.</p>
{/snippet}
<!-- ...TodoList is always rendered, unconditionally, regardless of visible.length -->
Show the answer

A {#snippet} block is a value, not a side-effecting declaration — defining it does nothing on its own; only {@render name()} puts its markup on the page. Nothing about this fails to compile or warns: the snippet is simply dead code, and the empty-filter case renders an empty <ul> with no explanation.

✓ EmptyStateBad: never shows the empty-state snippet because it is declared but never {@render}ed
Test Files 1 passed (1)
Tests 1 passed (1)

The fix is an {#if visible.length === 0}{@render emptyState()}{:else}...{/if} around the two branches, proven in the Snippets & Render lesson to be the actual contract a snippet’s name carries.

Events and the DOM: on:click instead of onclick

Section titled “Events and the DOM: on:click instead of onclick”

The filter buttons use the Svelte 4 event-directive syntax.

🐛 Spot the bug

<button aria-pressed={filter === f} on:click={() => (filter = f)}>{f}</button>
Show the answer

Unlike export let/$:, this is real, working syntax that the compiler still recognizes and lowers correctly — it degrades to a deprecation warning, not an error:

Warn: Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead
https://svelte.dev/e/event_directive_deprecated

The fix is onclick, a plain property like every other Svelte 5 event handler, proven with the full legacy-recognition table in Events & Attributes.

Keys and identity: a keyless {#each} with a stateful child

Section titled “Keys and identity: a keyless {#each} with a stateful child”

TodoList iterates todos with no (todo.id) key, and each TodoItem holds its own local selected state — purely local, tied to nothing in the data.

🐛 Spot the bug

{#each todos as todo}
<TodoItem {todo} {onToggle} {onRename} />
{/each}
Show the answer

Without a key, Svelte reuses each list position’s existing component instance — and whatever local state it holds — by position, not by the todo’s identity. As long as the list never reorders, this looks completely fine. The moment an item is prepended — exactly what “Undo” and this app’s own history can do — the OLD row’s local state (its selected checkbox) stays attached to whatever item now happens to sit at that same index.

✓ TodoListBad (keyless each): bleeds per-row selection to the WRONG row after prepending an item
Test Files 1 passed (1)
Tests 1 passed (1)

The fix is {#each todos as todo (todo.id)} — a stable id from the data, never the row’s current position.

src/lib/todos/types.ts
export type Todo = { id: string; text: string; done: boolean };
export type Filter = 'all' | 'active' | 'completed';
src/lib/todos/TodoStats.svelte
<script lang="ts">
import type { Todo } from './types';
let { todos }: { todos: Todo[] } = $props();
// FIX (runes vs legacy): $props() and $derived, not export let/$:.
let activeCount = $derived(todos.filter((t) => !t.done).length);
let percentDone = $derived(
todos.length === 0 ? 0 : Math.round(((todos.length - activeCount) / todos.length) * 100),
);
</script>
<dl>
<dt>Active</dt><dd data-testid="active-count">{activeCount}</dd>
<dt>Done</dt><dd data-testid="percent-done">{percentDone}%</dd>
</dl>
src/lib/todos/CompletedBadge.svelte
<script lang="ts">
import type { Todo } from './types';
let { todos }: { todos: Todo[] } = $props();
// FIX (state ownership): $derived, no $effect, no separate $state variable.
let doneCount = $derived(todos.filter((t) => t.done).length);
</script>
<p data-testid="done-count">{doneCount} done</p>
src/lib/todos/EditableLabel.svelte
<script lang="ts">
import { untrack } from 'svelte';
let { text, onSave }: { text: string; onSave: (v: string) => void } = $props();
// FIX (mirrored prop): untrack makes the "read once at mount" intentional
// (silences state_referenced_locally honestly, instead of ignoring it),
// and the $effect below resyncs draft whenever text changes from OUTSIDE.
let draft = $state(untrack(() => text));
$effect(() => {
draft = text;
});
</script>
<input data-testid="draft" bind:value={draft} onblur={() => onSave(draft)} />
src/lib/todos/TodoItem.svelte
<script lang="ts">
import type { Todo } from './types';
import EditableLabel from './EditableLabel.svelte';
let {
todo,
onToggle,
onRename,
}: { todo: Todo; onToggle: (id: string) => void; onRename: (id: string, text: string) => void } = $props();
let selected = $state(false);
</script>
<li>
<input type="checkbox" data-testid="select" bind:checked={selected} />
<input type="checkbox" data-testid="done" checked={todo.done} onchange={() => onToggle(todo.id)} />
<EditableLabel text={todo.text} onSave={(v) => onRename(todo.id, v)} />
</li>
src/lib/todos/TodoList.svelte
<script lang="ts">
import type { Todo } from './types';
import TodoItem from './TodoItem.svelte';
let {
todos,
onToggle,
onRename,
}: { todos: Todo[]; onToggle: (id: string) => void; onRename: (id: string, text: string) => void } = $props();
</script>
<!-- FIX (keys & identity): key by todo.id so per-row local state (selected)
follows the right todo across a reorder (undo, prepend). -->
<ul>
{#each todos as todo (todo.id)}
<TodoItem {todo} {onToggle} {onRename} />
{/each}
</ul>
src/lib/todos/TodoApp.svelte
<script lang="ts">
import type { Filter, Todo } from './types';
import TodoList from './TodoList.svelte';
import TodoStats from './TodoStats.svelte';
import CompletedBadge from './CompletedBadge.svelte';
let nextId = 1;
let todos = $state<Todo[]>([]);
let filter = $state<Filter>('all');
let history = $state<Todo[][]>([]);
function pushHistory() {
history.push(todos.map((t) => ({ ...t })));
}
function addTodo(text: string) {
pushHistory();
todos.push({ id: String(nextId++), text, done: false });
}
function toggleTodo(id: string) {
pushHistory();
const t = todos.find((t) => t.id === id);
if (t) t.done = !t.done;
}
function renameTodo(id: string, text: string) {
pushHistory();
const t = todos.find((t) => t.id === id);
if (t) t.text = text;
}
function undo() {
const prev = history.pop();
if (prev) todos = prev;
}
let visible = $derived(
todos.filter((t) => (filter === 'active' ? !t.done : filter === 'completed' ? t.done : true)),
);
</script>
{#snippet emptyState()}
<p data-testid="empty">No todos match this filter.</p>
{/snippet}
<div>
<button onclick={() => addTodo(`Task ${nextId}`)}>Add</button>
<button onclick={undo} disabled={history.length === 0}>Undo</button>
<div role="group">
<!-- FIX (events & DOM): onclick, not the deprecated on:click. -->
{#each ['all', 'active', 'completed'] as const as f}
<button aria-pressed={filter === f} onclick={() => (filter = f)}>{f}</button>
{/each}
</div>
<!-- FIX (props/bindings/snippets): the empty-state snippet is now actually rendered. -->
{#if visible.length === 0}
{@render emptyState()}
{:else}
<TodoList todos={visible} onToggle={toggleTodo} onRename={renameTodo} />
{/if}
<TodoStats {todos} />
<CompletedBadge {todos} />
</div>
src/lib/todos/todos.svelte.test.ts
import { describe, it, expect, vi } from 'vitest';
import { flushSync } from 'svelte';
import { render, screen, fireEvent } from '@testing-library/svelte';
import EditableLabel from './EditableLabel.svelte';
import CompletedBadge from './CompletedBadge.svelte';
import TodoStats from './TodoStats.svelte';
import TodoItem from './TodoItem.svelte';
import TodoList from './TodoList.svelte';
describe('EditableLabel (fixed): resyncs the draft whenever text changes from outside', () => {
it('updates the input after an upstream change (e.g. undo)', async () => {
const onSave = () => {};
const { rerender } = render(EditableLabel, { props: { text: 'buy milk', onSave } });
expect((screen.getByTestId('draft') as HTMLInputElement).value).toBe('buy milk');
await rerender({ text: 'buy bread', onSave });
expect((screen.getByTestId('draft') as HTMLInputElement).value).toBe('buy bread');
});
});
describe('CompletedBadge (fixed): $derived needs no separate $effect commit', () => {
it('reflects a todos change synchronously, in the same update as the render', async () => {
const { rerender } = render(CompletedBadge, { props: { todos: [{ id: '1', text: 'a', done: false }] } });
expect(screen.getByTestId('done-count').textContent).toBe('0 done');
await rerender({ todos: [{ id: '1', text: 'a', done: true }] });
expect(screen.getByTestId('done-count').textContent).toBe('1 done');
});
it('bare-state proof: mirroring a derived value via $effect lags one flush behind $derived', () => {
const destroy = $effect.root(() => {
let doneCount = $state(0);
let todos = $state([{ done: false }]);
$effect(() => {
doneCount = todos.filter((t) => t.done).length;
});
flushSync();
expect(doneCount).toBe(0);
todos[0].done = true;
expect(doneCount).toBe(0); // stale — read before the effect's next flush
let doneCountDerived = $derived(todos.filter((t) => t.done).length);
expect(doneCountDerived).toBe(1); // correct instantly, no flush needed
flushSync();
expect(doneCount).toBe(1);
});
destroy();
});
});
describe('TodoStats (fixed): $derived recomputes every render', () => {
it('updates activeCount as todos change', async () => {
const { rerender } = render(TodoStats, { props: { todos: [{ id: '1', text: 'a', done: false }] } });
expect(screen.getByTestId('active-count').textContent).toBe('1');
await rerender({ todos: [{ id: '1', text: 'a', done: false }, { id: '2', text: 'b', done: false }] });
expect(screen.getByTestId('active-count').textContent).toBe('2');
});
});
describe('TodoItem select checkbox', () => {
it('keeps its own local selection state independent of unrelated prop changes', async () => {
const onToggle = () => {};
const onRename = () => {};
const { rerender } = render(TodoItem, {
props: { todo: { id: 'a', text: 'Alpha', done: false }, onToggle, onRename },
});
await fireEvent.click(screen.getByTestId('select'));
expect((screen.getByTestId('select') as HTMLInputElement).checked).toBe(true);
await rerender({ todo: { id: 'a', text: 'Alpha', done: true }, onToggle, onRename });
expect((screen.getByTestId('select') as HTMLInputElement).checked).toBe(true);
});
});
describe('TodoList (fixed): key={todo.id} keeps per-row state on the RIGHT row after a reorder', () => {
it('the selection stays on the item it was checked on, not on position 0', async () => {
const noop = () => {};
const items = [
{ id: 'a', text: 'Alpha', done: false },
{ id: 'b', text: 'Bravo', done: false },
];
const { container, rerender } = render(TodoList, { props: { todos: items, onToggle: noop, onRename: noop } });
const selects = () => Array.from(container.querySelectorAll('[data-testid="select"]')) as HTMLInputElement[];
await fireEvent.click(selects()[0]); // check Alpha, currently at position 0
expect(selects().map((b) => b.checked)).toEqual([true, false]);
await rerender({
todos: [{ id: 'z', text: 'Zulu', done: false }, ...items],
onToggle: noop,
onRename: noop,
});
// Zulu is now at position 0, but the checked state followed Alpha (now position 1)
expect(selects().map((b) => b.checked)).toEqual([false, true, false]);
});
});

Run against the exact test file above, with svelte-check clean on every corrected file:

✓ EditableLabel (fixed): resyncs the draft whenever text changes from outside > updates the input after an upstream change (e.g. undo)
✓ CompletedBadge (fixed): $derived needs no separate $effect commit > reflects a todos change synchronously, in the same update as the render
✓ CompletedBadge (fixed): $derived needs no separate $effect commit > bare-state proof: mirroring a derived value via $effect lags one flush behind $derived
✓ TodoStats (fixed): $derived recomputes every render > updates activeCount as todos change
✓ TodoItem select checkbox > keeps its own local selection state independent of unrelated prop changes
✓ TodoList (fixed): key={todo.id} keeps per-row state on the RIGHT row after a reorder > the selection stays on the item it was checked on, not on position 0
Test Files 1 passed (1)
Tests 6 passed (6)
#BugCategoryFix
1export let/$: mixed into a runes-mode fileRunes vs legacy$props() + $derived — both legacy forms are hard compile errors, not warnings
2$effect writing doneCount instead of deriving itState ownershipDelete the $effect and the separate $state; use $derived
3$state(text) mirrors a prop once, never resyncsState ownershipResync via $effect; silence the (correct) warning with untrack
4{#snippet emptyState} defined, never {@render}edProps/bindings/snippets{@render emptyState()} inside an {#if visible.length === 0}
5on:click on the filter buttonsEvents & DOMonclick — the same syntax, deprecated but still working
6Keyless {#each todos as todo} with a stateful TodoItemKeys & identity{#each todos as todo (todo.id)}

Two of these six bugs never reach runtime at all — svelte-check refuses to compile them, because runes mode and legacy mode are two separate compiler passes that cannot be merged. The other four all compile clean and all look correct on a single render: an effect-mirrored value is wrong for a window most manual testing never lands in, a mirrored prop is wrong only after a second, external change, a dead snippet is wrong only when the list happens to be empty, and a keyless list is wrong only after a reorder. Reading the code once proves almost nothing about any of the last four; only running it — a real svelte-check pass plus a real vitest run against fake reorders and prop updates — does.

  • “If it’s in a .svelte file, export let and $state can mix — Svelte figures it out.” Proven false, twice, with the real error text: the moment any rune appears anywhere in a file (or the project forces runes mode), export let and $: are hard compile errors, not a softer “pick one” warning.
  • “A $state variable initialized from a prop stays in sync with that prop.” Proven false: $state(text) reads text exactly once. Svelte’s own compiler even warns about this specific line — state_referenced_locally — which is worth trusting rather than dismissing.
  • “Defining a {#snippet} is enough to make it appear.” A snippet is a value; only {@render name()} puts it on the page. An unrendered snippet produces no error, warning, or console output of any kind.
  • “A missing list key only matters for performance.” Proven false with a reorder test: without a key, an unrelated row’s local state (here, a selection checkbox) follows the list position, not the data — a correctness bug, not a performance one.
flowchart TD
  A["1. Runes vs legacy mode"] --> A1["export let / $: mixed into a runes file"]
  B["2. State ownership"] --> B1["$effect writing a derived value"]
  B --> B2["prop mirrored into $state, never resynced"]
  C["3. Props, bindings, snippets"] --> C1["snippet defined, never {@render}ed"]
  D["4. Events and the DOM"] --> D1["on:click instead of onclick"]
  E["6. Keys, transitions, boundaries"] --> E1["keyless {#each} with a stateful child"]
Where each bug sits in the reading order
What happens when this file compiles in a project where runes mode is forced?
<script>
  export let todos;
  $: activeCount = todos.filter((t) => !t.done).length;
</script>
CompletedBadge computes doneCount with $effect(() => { doneCount = todos.filter(t => t.done).length }) instead of $derived. Why is this a real bug, not just a style choice?
What does svelte-check say about the $state(text) line, unprompted?
let { text, onSave } = $props();
let draft = $state(text);
A {#snippet emptyState()}...{/snippet} block is declared in a component but {@render emptyState()} never appears anywhere. What happens?
Each TodoItem has its own local selected state. What is observed after checking the first row and then prepending a new todo?
{#each todos as todo}
  <TodoItem {todo} />
{/each}
Why does on:click={() => (filter = f)} compile successfully while export let/$: do not, even though all three are Svelte 4 patterns?