Worked review 2: a dashboard with search, a chart, and async data
The feature
Section titled “The feature”The prompt was: “Build a dashboard with a threshold slider to filter data points, a chart widget showing the top point, a tooltip on the slider, an async ‘total’ stat, and legend settings the user can toggle.” This draft leans on four Svelte 5 features an AI assistant reaches for constantly — bind:, use: actions, $state.raw, and await in the template — and gets the contract of every single one slightly wrong. Read it in this module’s reading order: after runes vs legacy and state ownership (this review’s predecessor), the next two steps are props/bindings/snippets and events/DOM — exactly where four of these 5 real bugs live. Two are hard compile errors.
The generated code
Section titled “The generated code”// src/lib/dashboard/types.ts — as generatedexport type DataPoint = { label: string; value: number };<!-- src/lib/dashboard/RangeFilter.svelte — as generated --><script lang="ts"> let { min }: { min: number } = $props();</script><input type="range" bind:value={min} min="0" max="100" />// src/lib/dashboard/tooltip.ts — as generatedexport function tooltip(node: HTMLElement, text: string) { node.title = text; // set once at mount — nothing notices a later change}<!-- src/lib/dashboard/ChartWidget.svelte — as generated --><script lang="ts"> import type { DataPoint } from './types'; let { points }: { points: DataPoint[] } = $props(); let max = $derived(Math.max(...points.map((p) => p.value))); let top = $derived(points.find((p) => p.value === max)!); // undefined when points is empty</script><p>{top.label}</p><!-- src/lib/dashboard/ChartSettings.svelte — as generated --><script lang="ts"> let options = $state.raw({ color: 'teal', showLegend: true });
function toggleLegend() { options.showLegend = !options.showLegend; // mutation — $state.raw is NOT deep-reactive }</script><button onclick={toggleLegend}>Toggle legend</button><p>{options.showLegend ? 'legend on' : 'legend off'}</p><!-- src/lib/dashboard/AsyncStat.svelte — as generated --><script lang="ts"> let { load }: { load: () => Promise<number> } = $props();</script><p>Total: {await load()}</p><!-- src/lib/dashboard/Dashboard.svelte — as generated --><script lang="ts"> import type { DataPoint } from './types'; import { tooltip } from './tooltip'; import RangeFilter from './RangeFilter.svelte'; import ChartWidget from './ChartWidget.svelte'; import ChartSettings from './ChartSettings.svelte'; import AsyncStat from './AsyncStat.svelte';
let { points, load }: { points: DataPoint[]; load: () => Promise<number> } = $props(); let threshold = $state(0); let visible = $derived(points.filter((p) => p.value >= threshold));</script>
<h2>Dashboard</h2><div use:tooltip={`${visible.length} of ${points.length} points shown`}> <RangeFilter bind:min={threshold} /></div>
<ChartWidget points={visible} /><ChartSettings /><AsyncStat {load} />The review walk
Section titled “The review walk”Props, bindings, and snippets: bind: to a non-$bindable prop
Section titled “Props, bindings, and snippets: bind: to a non-$bindable prop”RangeFilter never opts its min prop into two-way binding, but Dashboard binds to it anyway.
🐛 Spot the bug
let { min }: { min: number } = $props();
<!-- Dashboard.svelte --><RangeFilter bind:min={threshold} />Show the answer
A prop flows one way unless the child explicitly grants a two-way surface with $bindable(). This is a real, caught svelte-check error that names the exact fix — not a silent no-op:
Error: Cannot use 'bind:' with this property. It is declared as non-bindable inside the component.To mark a property as bindable: 'let { min = $bindable() } = $props()' (ts)The fix is let { min = $bindable() }: { min: number } = $props(); inside RangeFilter — proven in the Bindings lesson to be required before any bind: to a component prop is legal.
Events and the DOM: a use: action that never re-runs
Section titled “Events and the DOM: a use: action that never re-runs”tooltip sets node.title once, at mount, from whatever text happened to be at that moment.
🐛 Spot the bug
export function tooltip(node: HTMLElement, text: string) {node.title = text; // set once at mount}Show the answer
An action’s function body runs exactly once, when the element mounts. Unless it returns an update(newArg) method, Svelte has no way to know the argument even can change — and visible.length here changes every time the threshold slider moves.
✓ TooltipButtonBad: never updates node.title after the text prop changes — no update() on the action
Test Files 1 passed (1) Tests 1 passed (1)The fix is returning { update(newText) { node.title = newText; } }, proven in the Actions lesson to be the one thing that makes an action’s argument actually reactive (an @attach attachment gets this for free, re-running on every reactive read, which is why this course’s Actions lesson recommends attachments for new code — use: remains for library interop).
Props, bindings, and snippets: await in a template with no experimental.async
Section titled “Props, bindings, and snippets: await in a template with no experimental.async”AsyncStat awaits directly in a template expression.
🐛 Spot the bug
<p>Total: {await load()}</p>Show the answer
await in a template, a $derived, or at a component’s top level is a real compile error unless the project turns on an experimental compiler flag — most AI-generated code never mentions it, because it predates this feature:
Error: Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless the `experimental.async` compiler option is `true`https://svelte.dev/e/experimental_asyncWith experimental.async: true (this course’s own vite.config.ts already sets it project-wide, same as the Async Svelte lesson proves), the identical source compiles with zero errors and zero warnings. The fix here isn’t changing the component at all — it’s making sure the project the AI generated this for actually has the flag on, and wrapping the await in a <svelte:boundary pending> for loading UI (shown in the corrected code).
Keys, transitions, and boundaries: no <svelte:boundary> around a widget that throws
Section titled “Keys, transitions, and boundaries: no <svelte:boundary> around a widget that throws”ChartWidget throws when points is empty (Math.max() of nothing is -Infinity, and no point’s value equals it, so .find() returns undefined and top.label throws). Dashboard renders it with no boundary at all.
🐛 Spot the bug
<h2>Dashboard</h2><ChartWidget points={visible} /><!-- no <svelte:boundary> anywhere -->Show the answer
By default, an error thrown while rendering unmounts the entire nearest root, not just the failing widget — an empty filter result (a completely normal, reachable state, not an edge case) takes down the whole dashboard, including the slider and the async stat that have nothing to do with the chart.
✓ DashboardNoBoundaryBad: an empty points array crashes the WHOLE dashboard, uncaught
Test Files 1 passed (1) Tests 1 passed (1)The fix, proven in the Lifecycle lesson’s full <svelte:boundary> coverage, is wrapping just ChartWidget in a boundary with a {#snippet failed} — the rest of the dashboard survives untouched.
State ownership: $state.raw mutated instead of reassigned
Section titled “State ownership: $state.raw mutated instead of reassigned”ChartSettings mutates a property of a $state.raw object directly.
🐛 Spot the bug
let options = $state.raw({ color: 'teal', showLegend: true });function toggleLegend() {options.showLegend = !options.showLegend; // mutation}Show the answer
$state.raw opts out of the deep-reactive Proxy wrapper — proven in the $state Rune lesson, only whole-cell reassignment counts as a write; mutating the contents changes the data in memory but never notifies anything, so the UI silently never updates:
✓ ChartSettingsBad: mutating $state.raw in place does not trigger a re-render
Test Files 1 passed (1) Tests 1 passed (1)The fix is options = { ...options, showLegend: !options.showLegend } — a brand-new object, which $state.raw does react to.
The corrected code
Section titled “The corrected code”export type DataPoint = { label: string; value: number };<script lang="ts"> // FIX (bindings): $bindable() grants the two-way surface bind: assumes. let { min = $bindable() }: { min: number } = $props();</script><input type="range" data-testid="threshold" min="0" max="100" bind:value={min} />export function tooltip(node: HTMLElement, text: string) { node.title = text; // FIX (events & DOM): update() is what makes the argument reactive. return { update(newText: string) { node.title = newText; }, };}<script lang="ts"> import type { DataPoint } from './types'; let { points }: { points: DataPoint[] } = $props(); let max = $derived(Math.max(...points.map((p) => p.value))); let top = $derived(points.find((p) => p.value === max)!);</script><p data-testid="top-label">{top.label}</p><script lang="ts"> let options = $state.raw({ color: 'teal', showLegend: true });
function toggleLegend() { // FIX (state ownership): reassign a NEW object — $state.raw only reacts // to reassignment, never to a mutation of the existing object. options = { ...options, showLegend: !options.showLegend }; }</script><button data-testid="toggle" onclick={toggleLegend}>Toggle legend</button><p data-testid="legend">{options.showLegend ? 'legend on' : 'legend off'}</p><script lang="ts"> let { load }: { load: () => Promise<number> } = $props();</script><!-- FIX (props/bindings/snippets + keys/boundaries): a boundary with a pending snippet gates the await — needs experimental.async: true, already set project-wide in this course's vite.config.ts. --><svelte:boundary> <p data-testid="total">Total: {await load()}</p> {#snippet pending()} <p data-testid="loading">Loading…</p> {/snippet}</svelte:boundary><script lang="ts"> import type { DataPoint } from './types'; import { tooltip } from './tooltip'; import ThresholdSlider from './ThresholdSlider.svelte'; import ChartWidget from './ChartWidget.svelte'; import ChartSettings from './ChartSettings.svelte'; import AsyncStat from './AsyncStat.svelte';
let { points, load }: { points: DataPoint[]; load: () => Promise<number> } = $props(); let threshold = $state(0); let visible = $derived(points.filter((p) => p.value >= threshold));</script>
<h2>Dashboard</h2><div use:tooltip={`${visible.length} of ${points.length} points shown`}> <ThresholdSlider bind:min={threshold} /></div>
<!-- FIX (keys/transitions/boundaries): only the widget that can throw is inside the boundary — the rest of the dashboard survives its failure. --><svelte:boundary onerror={() => {}}> <ChartWidget points={visible} /> {#snippet failed(error: unknown, reset: () => void)} <p data-testid="chart-error">Could not display the chart.</p> {/snippet}</svelte:boundary>
<ChartSettings /><AsyncStat {load} />import { describe, it, expect, vi } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import ThresholdSlider from './ThresholdSlider.svelte';import ChartWidget from './ChartWidget.svelte';import ChartSettings from './ChartSettings.svelte';import AsyncStat from './AsyncStat.svelte';
describe('ThresholdSlider (fixed): $bindable() lets the parent bind: to it', () => { it('a slider change propagates back to the bound parent variable', async () => { let min = $state(0); render(ThresholdSlider, { props: { get min() { return min; }, set min(v) { min = v; } } }); await fireEvent.input(screen.getByTestId('threshold'), { target: { value: '42' } }); expect(min).toBe(42); });});
describe('tooltip action (fixed): update() re-runs when the argument changes', () => { it('node.title tracks the latest text via update()', async () => { const { tooltip } = await import('./tooltip'); const node = document.createElement('div'); const action = tooltip(node, 'first'); expect(node.title).toBe('first'); action?.update?.('second'); expect(node.title).toBe('second'); });});
describe('ChartSettings (fixed): reassigning $state.raw triggers a re-render', () => { it('toggling legend flips the rendered text', async () => { render(ChartSettings); expect(screen.getByTestId('legend').textContent).toBe('legend on'); await fireEvent.click(screen.getByTestId('toggle')); expect(screen.getByTestId('legend').textContent).toBe('legend off'); });});
describe('ChartWidget inside a <svelte:boundary>: contained, not fatal', () => { it('an empty points array still throws (the widget itself is unchanged)', () => { expect(() => render(ChartWidget, { props: { points: [] } })).toThrow(); });});
describe('AsyncStat (fixed): await in the template, gated by experimental.async', () => { it('shows the pending snippet, then the resolved total', async () => { let resolveLoad!: (v: number) => void; const load = () => new Promise<number>((resolve) => { resolveLoad = resolve; }); render(AsyncStat, { props: { load } }); expect(screen.getByTestId('loading')).toBeInTheDocument(); resolveLoad(123); await vi.waitFor(() => expect(screen.getByTestId('total').textContent).toBe('Total: 123')); });});✓ ThresholdSlider (fixed): $bindable() lets the parent bind: to it > a slider change propagates back to the bound parent variable✓ tooltip action (fixed): update() re-runs when the argument changes > node.title tracks the latest text via update()✓ ChartSettings (fixed): reassigning $state.raw triggers a re-render > toggling legend flips the rendered text✓ ChartWidget inside a <svelte:boundary>: contained, not fatal > an empty points array still throws (the widget itself is unchanged)✓ AsyncStat (fixed): await in the template, gated by experimental.async > shows the pending snippet, then the resolved total
Test Files 1 passed (1) Tests 5 passed (5)svelte-check on every corrected file above: 0 errors and 0 warnings.
What changed and why
Section titled “What changed and why”| # | Bug | Category | Fix |
|---|---|---|---|
| 1 | bind:min to a prop never marked $bindable() | Props/bindings/snippets | let { min = $bindable() } = $props() |
| 2 | use:tooltip={text} with no update() | Events & DOM | Return { update(newText) {...} } from the action |
| 3 | await in the template with no experimental.async | Props/bindings/snippets | Enable the flag project-wide; gate the await in <svelte:boundary pending> |
| 4 | No <svelte:boundary> around a widget that can throw | Keys/transitions/boundaries | Wrap just ChartWidget in a boundary with {#snippet failed} |
| 5 | $state.raw object mutated in place | State ownership | Reassign a new object instead of mutating the old one |
Mental Model
Section titled “Mental Model”Every bug in this review is a contract violation, not a syntax mistake: bind: assumes the child opted in, use: assumes the action re-runs the way a reactive prop would, await in a template assumes an experimental flag is on, and mutating $state.raw assumes the same reactivity $state gives you for free. Two of the five contracts are enforced by the compiler itself (bind, await); the other three are silent, and only a running probe — an actual reorder, an actual re-render count, an actual thrown error — reveals which one was broken.
Common Misconceptions
Section titled “Common Misconceptions”- “
bind:always works if the child has a prop with that name.” Proven false with a realsvelte-checkerror: a prop is one-way by default, andbind:to it is rejected unless the child wrote$bindable(). - “A
use:action’s argument is reactive automatically, like a prop.” Proven false: without anupdate()method, the action’s function body runs once, at mount, and never again. - “
awaitin a template just works in any Svelte 5 project.” Proven false with the real compiler error: it needsexperimental.async: trueexplicitly, and is still labeled experimental. - “If a widget can throw, wrapping the whole page in one
<svelte:boundary>is enough.” Technically true, but it means any failure anywhere unmounts the entire page. This review’s fix scopes the boundary to just the widget that can actually fail. - “
$state.rawstill lets you mutate nested properties, it just skips deep proxying for performance.” Proven false: mutating it is not merely “less optimized,” it is silently ignored by the reactivity system entirely.
flowchart TD A["3. Props, bindings, snippets"] --> A1["bind: to a non-$bindable prop"] A --> A2["await in a template, no experimental.async"] B["4. Events and the DOM"] --> B1["use: action with no update()"] C["6. Keys, transitions, boundaries"] --> C1["no <svelte:boundary> around a throwing widget"] D["2. State ownership"] --> D1["$state.raw mutated instead of reassigned"]