Skip to content

How to read Svelte you did not write

Every earlier module in this course taught you to write Svelte. This module teaches the other half: reading Svelte someone — or something — else wrote, and deciding whether it is correct. An AI assistant hands you a component that compiles, that svelte-check accepts, and that looks exactly like the examples you have been studying. None of that proves it is correct. Svelte’s own compiler is unusually good at catching syntax mistakes and unusually quiet about behavioral ones: a $effect that mirrors state a beat too late, a {#each} block that silently swaps two rows’ internal state, a form action that returns the right shape with the wrong HTTP status. The six steps below are the order that actually surfaces these failures, and every later lesson in this module drills one part of it.

flowchart TD
  A["1. Runes vs legacy mode<br/>which mode is this file even in?"] --> B["2. State ownership<br/>$state vs $derived vs $effect, module state"]
  B --> C["3. Props, bindings, snippets<br/>$bindable, {@render}"]
  C --> D["4. Events and DOM<br/>onclick, delegation, attachments vs actions"]
  D --> E["5. The SvelteKit boundary<br/>server vs universal load, actions, hooks, env"]
  E --> F["6. Keys, transitions, boundaries"]
Reading order for a Svelte file you did not write

1. Runes vs legacy mode: which mode is this file even in?

Section titled “1. Runes vs legacy mode: which mode is this file even in?”

Before you trust anything else about a .svelte file, work out which of Svelte’s two incompatible reactivity systems it is written in. The compiler decides per file, automatically, from that file’s own contents: the moment any rune ($state, $derived, $effect, $props, …) appears anywhere in the file, the whole file is in runes mode, and Svelte 4’s reactivity primitives (export let, a top-level reactive $:) become hard compile errors rather than working differently. An AI assistant trained on a mix of Svelte 4 and Svelte 5 examples routinely produces a file that starts a rune-mode rewrite and stops halfway — the single most common shape of AI-generated Svelte bug.

  • What to look for: does the file mix a rune with export let, a top-level reactive $: statement, <slot>, on:click-style event directives, or createEventDispatcher? The first two are hard compile errors the instant any rune is present; the middle two are real, but softer, deprecation warnings; the last one compiles with no signal at all. Knowing which bucket a given legacy pattern falls into changes how urgently it needs fixing.
  • The question to ask: if I compiled this file right now, would the reactivity primitives (export let, $:) even be legal here — and separately, is the syntax (<slot>, on:click) merely outdated, or the idiom (createEventDispatcher) merely unfashionable?
  • Which lesson explains it: Why Svelte, the Compiler proves per-file mode detection directly with compile()’s metadata.runes; Events & Attributes has the full four-pattern legacy-recognition table with every real error/warning text; the AI Bug Catalog’s first group works through all four side by side.

Every $state declaration is a claim about ownership: this is the one source of truth for this value, computed once and read wherever it’s needed. AI-generated code frequently duplicates ownership anyway — mirroring a prop into $state and syncing it with $effect instead of deriving it, writing a computed value inside $effect instead of $derived, or exporting module-level $state that is silently shared by every request on a server.

  • What to look for: an $effect that only assigns to another $state variable (should almost always be $derived); a value read inside setTimeout/await/any callback that runs after the surrounding $effect or $derived has already finished its synchronous pass (not a tracked dependency, no matter how reactive it looks); $state.raw mutated in place instead of reassigned; a .svelte.ts/.svelte.js module exporting $state that gets written to inside a load function or a request handler.
  • The question to ask: could this value just be $derived from what it already depends on — and if this file’s module-level state is mutated on the server, is that safe for every concurrent request, or does it leak one user’s data into the next?
  • Which lesson explains it: The $effect Rune proves the effect-mirror lag with a running test; The $derived Rune proves push-pull laziness and why a $derived must stay free of side effects; The $state Rune proves $state.raw’s silent-mutation trap and the module-state SSR leak directly.

3. Props, bindings, and the snippet contract

Section titled “3. Props, bindings, and the snippet contract”

$props(), $bindable(), and {#snippet}/{@render} each carry a contract that is easy to get half-right: a prop flows one way unless the child explicitly opts into two-way binding, and a snippet is a value that must be rendered, not called like a function. AI-generated code regularly assumes the contract without checking it — binding to a prop the child never marked bindable, or writing a snippet’s name as a bare call.

  • What to look for: a parent doing <Child bind:value={x} /> when Child never wrote let { value = $bindable() } = $props(); a snippet referenced as {name(args)} instead of {@render name(args)}; a {#snippet} defined and never rendered anywhere at all; a use: action whose argument changes after mount with no update method to notice.
  • The question to ask: does the child component actually grant the two-way surface this bind: assumes — and everywhere a snippet’s name appears, is it behind {@render}?
  • Which lesson explains it: Bindings proves the exact svelte-check error for a missing $bindable(); Snippets & Render proves the runtime invalid_snippet_arguments error for a bare snippet call; Actions proves attachments re-run on argument change where use: actions silently don’t.

Svelte 5 event handlers are plain properties (onclick, not on:click), and most of them are delegated to a single root listener rather than attached per element. Both details matter for reading generated code that pokes at the DOM directly — a stopPropagation() on a vanilla listener between the root and a Svelte handler silently defeats it, and code assuming use: actions “just work” like reactive props misses that only an explicit update method (or, in new code, an attachment) makes that true.

  • What to look for: a manually-attached addEventListener between the DOM root and a Svelte onclick, especially one calling stopPropagation(); a use: action whose parameter changes after mount, with no update method; code that could be a component-level attachment instead, since use: only works on elements.
  • The question to ask: if something between the root and this element stops native event bubbling, does the delegated handler still fire — and does this piece of imperative DOM code actually re-run when its inputs change, or only once, at mount?
  • Which lesson explains it: Events & Attributes proves event delegation and the stopPropagation() gap directly with a real ancestor listener; Actions proves the attachment-vs-use: re-run difference with a counting test.

Code that runs on the server has sharp edges that don’t show up in a component-only review: redirect()/error() work by throwing, which a generically-written try/catch can silently swallow; a universal +page.js load runs in the browser too, so it can never import server-only env or touch a database; and a form action that returns a plain object instead of fail() looks correct in the UI while carrying the wrong HTTP status underneath.

  • What to look for: a try/catch around code that calls redirect()/error(), with no isRedirect/isHttpError check before treating the catch as a real failure; $env/static/private (or any server-only import) referenced from a +page.js instead of +page.server.js; a form action returning { error: '...' } instead of fail(status, { error: '...' }); a load that awaits data the first paint doesn’t need, instead of returning an unawaited promise for {#await} to stream; import { page } from '$app/stores' instead of $app/state.
  • The question to ask: if this exact code path threw a real redirect()/error(), would anything between the throw and SvelteKit’s own handler catch it by accident — and does every server-only import in this file actually live in a file that never ships to the browser?
  • Which lesson explains it: Server & API proves the redirect()-swallowed-by-try/catch bug with real curl output, plus the full hooks surface; Forms & Actions proves the fail()-vs-plain-object HTTP-status gap; Loading Data proves streaming and the $app/state-vs-$app/stores deprecation.

A {#each} block without a key doesn’t throw — it silently keeps reusing component instances by position, so removing an item from the middle of a list can leave a completely unrelated row holding the wrong internal state. <svelte:boundary> and async Svelte add one more layer: a pending snippet only ever covers a boundary’s first render, and await in a template is a real compile error without an experimental flag most generated code never mentions.

  • What to look for: {#each items as item} with no (item.id) key, especially when each row has its own local state (an expanded/collapsed toggle, a form draft); reset() called synchronously inside a <svelte:boundary onerror> callback; await used directly in a template or $derived with no compilerOptions.experimental.async: true anywhere in the project.
  • The question to ask: if this list reorders or shrinks, does each row’s own internal state follow its data, or does it silently stay pinned to a list position — and if this component throws mid-render, is there a boundary around it at all?
  • Which lesson explains it: Async Svelte proves the experimental_async compile error and the boundary/$effect.pending() split; Lifecycle proves the svelte_boundary_reset_onerror timing error; the AI Bug Catalog’s final group proves the keyless-{#each} state-swap bug end to end.

This module continues with the AI Bug Catalog (28 patterns, grouped in this exact order), a one-page Review Checklist, Verification Tools, and three fully worked reviews of AI-generated Svelte apps: Worked Review 1, Worked Review 2, and Worked Review 3.

Reading order for a Svelte file you did not write:
1. Runes vs legacy mode -> is every reactivity primitive even legal in this file?
2. State ownership -> does $state/$derived/$effect each do the job it is for?
3. Props/bindings/snippets -> does bind: have the $bindable() it assumes, is {@render} used?
4. Events and DOM -> does a stopPropagation() or a missing update() break this silently?
5. SvelteKit boundary -> does a try/catch swallow a throw, does server-only code leak client-side?
6. Keys, transitions, boundaries -> does list state follow the right row, is there a boundary at all?
Skipping straight to "does it pass svelte-check" answers almost none of these — most of this
module's catalog compiles clean, and several entries produce no warning of any kind.
  • “If svelte-check passes, the code is correct.” Most entries in this module’s catalog compile and type-check cleanly. svelte-check proves the shapes are valid and the compiler’s own hard rules are followed — never that the runtime behavior matches what the surrounding code assumes.
  • “A file is either ‘old Svelte’ or ‘new Svelte’, consistently throughout.” Proven false, repeatedly, in this module: a single file can mix a hard compile error (export let beside a rune) with a soft warning (on:click) with a pattern that compiles silently (createEventDispatcher) — recognizing which bucket each legacy pattern falls into matters more than a single “is this old” judgment.
  • “AI-generated Svelte needs more scrutiny than human-written Svelte.” Every pattern in this module’s catalog also appears in human-written code — an AI assistant just reproduces it with more confidence, and across more files, than a person usually does.
  • “If a component renders the right thing once, its state management is correct.” Several of this module’s bugs (the keyless-{#each} swap, the effect-mirror lag, the module-state SSR leak) only show up on the second interaction, reorder, or concurrent request — a single render proves almost nothing about ownership.
Why does this module check which compiler mode a file is in before checking anything about state ownership or events?
What happens when this compiles?
<script>
  export let count = 0;
  let doubled = $state(count * 2);
</script>
<p>{doubled}</p>
Why does an $effect that mirrors a computed value into a separate $state variable count as a state-ownership bug, not just a style preference?
What happens when this compiles, assuming Child never wrote $bindable()?
<!-- Child.svelte -->
<script>
  let { value } = $props();
</script>

<!-- Parent.svelte -->
<Child bind:value={x} />
Which step in this reading order specifically covers whether a try/catch might accidentally swallow a thrown redirect()?
Each Row has its own local expanded toggle. What is the risk of this {#each} block having no (row.id) key?
{#each rows as row}
  <Row {...row} />
{/each}