Skip to content

Worked review 2: a dashboard with search, a chart, and async data

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.

// src/lib/dashboard/types.ts — as generated
export 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 generated
export 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} />

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

RangeFilter.svelte
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_async

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

src/lib/dashboard/types.ts
export type DataPoint = { label: string; value: number };
src/lib/dashboard/ThresholdSlider.svelte
<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} />
src/lib/dashboard/tooltip.ts
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;
},
};
}
src/lib/dashboard/ChartWidget.svelte
<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>
src/lib/dashboard/ChartSettings.svelte
<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>
src/lib/dashboard/AsyncStat.svelte
<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>
src/lib/dashboard/Dashboard.svelte
<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} />
src/lib/dashboard/dashboard.svelte.test.ts
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.

#BugCategoryFix
1bind:min to a prop never marked $bindable()Props/bindings/snippetslet { min = $bindable() } = $props()
2use:tooltip={text} with no update()Events & DOMReturn { update(newText) {...} } from the action
3await in the template with no experimental.asyncProps/bindings/snippetsEnable the flag project-wide; gate the await in <svelte:boundary pending>
4No <svelte:boundary> around a widget that can throwKeys/transitions/boundariesWrap just ChartWidget in a boundary with {#snippet failed}
5$state.raw object mutated in placeState ownershipReassign a new object instead of mutating the old one

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.

  • “bind: always works if the child has a prop with that name.” Proven false with a real svelte-check error: a prop is one-way by default, and bind: to it is rejected unless the child wrote $bindable().
  • “A use: action’s argument is reactive automatically, like a prop.” Proven false: without an update() method, the action’s function body runs once, at mount, and never again.
  • “await in a template just works in any Svelte 5 project.” Proven false with the real compiler error: it needs experimental.async: true explicitly, 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.raw still 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"]
Where each bug sits in the reading order
What happens when this compiles, given RangeFilter never wrote $bindable()?
<!-- RangeFilter.svelte -->
let { min } = $props();

<!-- Dashboard.svelte -->
<RangeFilter bind:min={threshold} />
What happens to node.title after text changes on a later render?
export function tooltip(node, text) {
  node.title = text;
}
<p>Total: {await load()}</p> is compiled without experimental.async set anywhere. What is the result?
A ChartWidget that throws on an empty points array is rendered with no <svelte:boundary> anywhere on the page. What happens?
What happens in the UI after calling toggle()?
let options = $state.raw({ showLegend: true });
function toggle() {
  options.showLegend = !options.showLegend;
}
Why does this review say two of its five bugs are "hard compile errors" while the other three are "silent"?