deep·tech·intuition
intermediate ·

Svelte Deep Intuition

An experienced engineer's guide to Svelte

1. One-Sentence Essence

Svelte is a compiler that turns a declarative component file into the minimum imperative DOM-manipulation code your specific app needs — there is no runtime framework sitting between your code and the browser.

Read that again. Every other major framework (React, Vue, Angular, Solid) ships a framework as a dependency that runs in the browser alongside your code. Svelte does not. The thing called “Svelte” is a build-time tool. By the time your code reaches the browser, there is no Svelte — there is only the small amount of JavaScript that Svelte decided your specific buttons, lists, and components needed in order to keep the DOM in sync with your data.

Once this clicks, almost everything else about Svelte follows: why bundles are tiny, why there’s no virtual DOM, why reactivity uses special $state(...) syntax instead of hooks, why the .svelte file is a unique file format, why error messages mention “compiler errors,” and why criticisms about “magic” land where they do.


2. The Problem It Solved

In 2016, web framework orthodoxy was the virtual DOM: React (2013) and Vue (2014) both worked by rebuilding a JavaScript representation of your UI on every state change, diffing it against the previous version, and applying the minimum changes to the real DOM. This was a clever insight at the time. Direct DOM manipulation was tedious and error-prone, and the virtual DOM gave you a clean mental model: “describe what the UI should look like, and the framework figures out how to get there.”

But this came at a cost: you had to ship the framework to every user. A bare React + ReactDOM bundle was around 45KB gzipped before you wrote a single line of app code. The framework ran on every state change, every component, every keypress — doing the work of diffing trees that, for most updates, were 99% the same as before. The browser had to download it, parse it, execute it, and keep it in memory. On a top-of-the-line laptop with fiber internet this was invisible. On a $200 Android phone in São Paulo on a 3G network — the user that actually pays the bills on the modern web — it was punishing.

Rich Harris, a journalist-turned-developer at the New York Times Interactive News team, noticed something. He was building data-driven graphics that ran on news pages alongside other heavy content. He had a hard budget on bytes and CPU. The virtual DOM was a luxury he couldn’t afford. So he asked the question that became Svelte: what if the framework only existed at build time?

The insight: a component author tells the framework, declaratively, what the UI should look like for any given state. That description is static at build time. A compiler can look at <h1>Hello {name}!</h1> and generate, ahead of time, the exact line of code needed to update that text node when name changes — no diffing, no virtual DOM, no runtime reconciler. The framework becomes an implementation detail of the build, not a dependency of the deployed app.

The result was striking. A Svelte TodoMVC weighed roughly 3.6KB gzipped versus React’s 45KB-plus-app baseline. Updates were not just smaller — they were faster, because the compiled code didn’t need to compare anything; it knew which text node to touch. By 2018, Svelte 3 had refined this into the model that defined the framework for five years: write components in a file that looks like HTML with a <script> block, declare state with plain let, mark derived values and side effects with the cryptic $: label, and let the compiler do the rest.

This worked beautifully for components. It worked less well when your component grew up. The $: label was magic — fine when you were learning, mystifying when you were refactoring at 11pm. Reactivity only worked at the top level of a .svelte file, which meant moving state into a shared module forced you to learn an entirely different API called stores. TypeScript struggled to type any of it. By the time Svelte 4 shipped in 2023, the team knew the model needed an overhaul — not because it was wrong, but because it didn’t scale to the size of apps people were now building with it.

Svelte 5, released October 2024, is that overhaul. It introduces runes — explicit reactivity markers like $state, $derived, and $effect — that work everywhere, type cleanly, and remove the magic. The compiler-driven philosophy is unchanged. The reactivity model is rebuilt. This document teaches Svelte 5. If you read older tutorials or older blog posts that talk about let count = 0 being automatically reactive, that’s Svelte 4 — still supported but on its way out.


3. The Concepts You Need

You can’t have an intelligent conversation about Svelte without these. Read this section even if you’ve used React or Vue — some of the words look familiar but mean different things here.

Compiler-driven framework. Svelte’s defining trait. A .svelte file is not interpreted at runtime; it is compiled by the Svelte compiler into a regular JavaScript module that imports a tiny runtime helper library and contains the precise update logic for this component. There is no virtual DOM. The output is normal JS you could read, debug, and (if you really wanted to) write by hand.

.svelte file. A single-file component format. Three optional blocks: <script> for JavaScript, <style> for component-scoped CSS, and the rest of the file is HTML-like markup with template directives. Think Vue single-file components but with a different compiler.

Component. A reusable piece of UI defined by a .svelte file. Instantiated by importing it and using its name in markup: <MyButton />. Components have their own state and lifecycle, and can take props from a parent and pass children/snippets back.

Runes. The Svelte 5 reactivity API. Special $-prefixed pseudo-functions like $state(), $derived(), $effect(), $props(). They look like function calls but are actually compiler directives — keywords that the compiler recognizes and transforms. You can’t import them, you can’t pass them around as values, you can’t store them in variables. They’re part of Svelte’s syntax, not its library.

$state. The rune that declares reactive state. let count = $state(0) creates a variable that, when read by something that should depend on it (a template, a derived value, an effect), will register that dependency, and when written to will notify all dependents. Crucially, count is just a number — there’s no .value, no .get(), no wrapper. The reactivity is conjured by the compiler.

Proxy / state proxy. When you wrap an object or array in $state(...), Svelte returns a JavaScript Proxy that intercepts property reads and writes to enable fine-grained reactivity. This is why mutating todos[0].done works as expected — the Proxy tells Svelte exactly which property changed. It is also the source of multiple gotchas (see Section 7).

$derived. The rune for computed values. let doubled = $derived(count * 2) creates a value that automatically recomputes whenever count changes — but lazily, only when something actually reads doubled. Equivalent to useMemo in React, but with automatic dependency tracking and no dependency array.

$effect. The rune for side effects. $effect(() => { ... }) runs after the DOM has been updated and re-runs whenever any state read inside it changes. Used for things like analytics, drawing on canvases, integrating non-Svelte libraries. You should reach for this far less often than you think — see Section 7 and 8.

$props. The rune that declares a component’s incoming props. let { adjective, count = 0 } = $props() is how a component receives data from its parent. Defaults, renaming, and rest props all use plain JavaScript destructuring.

$bindable. A modifier on $props declarations that allows a child to write back to a prop, enabling two-way binding. let { value = $bindable() } = $props() says “I expect a parent to bind their state to my value, and I’m allowed to update it.”

Signal. A low-level reactivity primitive borrowed from Solid.js and adopted (in various forms) by Vue, Angular, and now Svelte 5. A signal is a piece of state that knows about its readers — when it changes, it notifies them. Svelte’s runes are syntactic sugar over an internal signal implementation. You will not interact with signals directly in normal code; you’ll see the term in advanced tutorials and the compiler’s output.

Push-pull reactivity. Svelte’s update model. When state changes, it eagerly pushes notification to everything that depends on it (so dependents know they’re “dirty”). But the actual recomputation is pulled lazily — $derived values aren’t recomputed until something reads them. This is efficient because state that nothing currently displays doesn’t waste cycles. Compare React’s “re-render the whole component tree and let the diff sort it out” model.

Reactive context (tracking context). The compiler-defined regions where reading a $state value registers a dependency. Template expressions ({count} in markup), $derived bodies, and $effect bodies are all tracking contexts. A regular function call or an event handler is not.

Snippet. A reusable chunk of markup with parameters, declared with {#snippet name(args)}...{/snippet} and invoked with {@render name(args)}. Replaces Svelte 4’s <slot> system. Snippets are first-class values — you can store them in variables, pass them as props, return them from functions. Think of them as “function components, but for markup, with closures.”

Render tag ({@render ...}). The template directive that actually renders a snippet. The companion to {#snippet ...}.

Children. Any content placed between a component’s opening and closing tags becomes an implicit snippet prop named children. Equivalent to React’s {children}, but it’s a function you have to call: {@render children()}.

Lifecycle hooks. onMount(fn) for setup after DOM insertion, onDestroy(fn) for cleanup. Imported from svelte. In Svelte 5 you’ll often replace onMount with an $effect (which also runs after mount) plus its returned teardown function.

Stores. The Svelte 4 mechanism for state outside components — writable(0), readable(...), derived(...) imported from svelte/store. Still supported in Svelte 5, but largely superseded by .svelte.js/.svelte.ts files that use runes. New code should generally use runes; old code using stores still works.

.svelte.js / .svelte.ts files. Plain JavaScript or TypeScript files where you’re allowed to use runes. This lets you define reactive state and derived values outside a component and share them across multiple components. The .svelte in the extension is the compiler’s signal that “this file needs to be processed for runes.”

Bindings (bind:). A template directive that creates two-way binding between an element’s property and a piece of state. <input bind:value={name} /> keeps name in sync with the input’s value. Works on DOM elements (form inputs, media, dimensions) and on bindable component props.

Actions (use:). A function attached to a DOM element to give it imperative behavior — e.g. use:tooltip to attach a tooltip library. Receives the element and runs setup/teardown. Being gradually superseded by attachments (@attach) in newer Svelte, but use: is what you’ll mostly see.

Transitions and animations (transition:, in:, out:, animate:). Built-in template directives for animating elements when they enter, leave, or move in a keyed list. A genuine Svelte differentiator — there is nothing this clean in React.

Compiler errors vs runtime errors. A category most frameworks don’t have. Because Svelte processes your file before it runs, it can refuse to compile code that’s nonsensical — using a rune outside a .svelte or .svelte.js file, putting $state in the wrong place, mutating a derived value declared with const. These are compiler errors. Runtime errors and runtime warnings are the same as in any other framework.

SvelteKit. A separate framework built on top of Svelte. SvelteKit adds routing, server-side rendering, static site generation, API endpoints, and deployment adapters. Svelte is to SvelteKit what React is to Next.js. If you’re building anything bigger than a widget, you’ll use SvelteKit. The two share branding and the same team, which is convenient but causes constant confusion when people say “Svelte” and mean “SvelteKit” or vice versa.


4. The Distilled Introduction

This section replaces ten hours of YouTube. By the end of it you’ll be able to write a real Svelte 5 component, manage state, compose components, handle forms, and ship a small app. The depth — why this works — comes in Section 5.

Setup

You almost never use Svelte on its own. You either use SvelteKit (the full app framework — routing, SSR, the works) or you mount Svelte components inside another project (Astro, Vite SPA, web components). For learning, go to svelte.dev/playground — it’s a browser-based IDE with no install. For a real project:

# The official scaffolder. Creates a SvelteKit project.
npx sv create my-app
cd my-app
npm install
npm run dev

The sv CLI replaces the older create-svelte and npm create svelte. It asks you about TypeScript, Prettier, ESLint, Playwright, Vitest, and Tailwind. Say yes to TypeScript — Svelte 5’s type story is the best it’s ever been and not having it is leaving money on the table.

What you get is a project where the things you care about live in src/routes/ (pages and API endpoints) and src/lib/ (shared components, utilities, and reactive state modules). The convention $lib is an import alias for src/lib/.

The .svelte file format

A component lives in a single file with three optional blocks:

<script lang="ts">
  // JavaScript or TypeScript that runs once per component instance
  let count = $state(0);
  function increment() { count += 1; }
</script>

<!-- HTML-ish markup with template directives -->
<button onclick={increment}>
  clicks: {count}
</button>

<style>
  /* Scoped to this component automatically */
  button { font-size: 1.5rem; }
</style>

Three things to notice immediately:

  1. let count = $state(0) declares reactive state. count is just a number — not a wrapper, not an object. Reading it gives you the number. Writing to it (count = 5 or count += 1) triggers updates everywhere it’s read.

  2. onclick={increment} is just a regular DOM property assignment expressed in template syntax. No on:click directive (that was Svelte 4), no onClick (that’s React), no custom event system. The whole event system in Svelte 5 collapses to “events are properties.”

  3. <style> is scoped by default. The compiler hashes the class names and rewrites your CSS so this button rule only applies inside this component. No CSS-in-JS overhead, no BEM ceremony, no global pollution.

There’s also a fourth optional block, <script module>, which runs once when the module first evaluates (not per instance). You’ll use it rarely — mostly for exporting helper values from a component file, or for state that should be shared across all instances of the component.

State: $state

You’ve already seen the basic case. The interesting stuff is what happens with objects and arrays.

<script>
  let todos = $state([
    { text: 'learn svelte', done: false },
    { text: 'write the app', done: false }
  ]);
</script>

When you wrap an object or array, Svelte returns a deeply reactive Proxy. This means mutations work — todos[0].done = true, todos.push({...}), todos.splice(...) — and the UI updates. You don’t need to spread-and-reassign like in React. The Proxy watches property accesses and invalidates downstream computations precisely.

This is the single most important thing to internalize. In React, you treat state as immutable: setTodos([...todos, newTodo]). In Svelte 5, you mutate freely: todos.push(newTodo). The compiler and the Proxy machinery handle the bookkeeping. If you fight this and try to write immutable Svelte, you’ll generate more re-renders than necessary and confuse yourself.

The Proxy is recursive — nested objects and arrays are wrapped too, until you hit something that isn’t a plain object/array (a class instance, a Date, a Map). For built-in types, Svelte ships reactive replacements in svelte/reactivity (SvelteMap, SvelteSet, SvelteDate, SvelteURL). Import these when you need them.

There are two escape hatches:

  • $state.raw(value) — declares state that is not deeply reactive. The Proxy is skipped. To update, you must reassign the whole value, not mutate it. Use this for large arrays/objects you don’t intend to mutate granularly — the proxy isn’t free.
  • $state.snapshot(value) — takes a deep, non-reactive plain-object copy. Use this when handing state to code that doesn’t understand Proxies (libraries doing structuredClone, IndexedDB serialization, JSON encoders that misbehave). See Section 7 — this matters more than you’d think.

Derived values: $derived

You almost never want a $state that depends on another $state. You want a derivation:

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
  let isLarge = $derived(count > 10);
</script>

<p>{count} doubled is {doubled}</p>
{#if isLarge}<p>that's a lot</p>{/if}

$derived is the workhorse of Svelte 5. Rules:

  • The expression must be side-effect-free. The compiler will refuse to compile $derived(count++). Reading state to compute the new value is the whole job.
  • Dependencies are tracked automatically. Whatever $state or other $derived values are read synchronously inside the expression become dependencies. No dependency arrays. No useMemo([count]).
  • It’s lazy. The expression is re-evaluated only when something reads doubled. If nothing in the UI currently shows it, the work doesn’t happen.
  • For complex computations, use $derived.by(() => { ... }) — same thing but the body is a function so you can use multiple statements.
<script>
  let numbers = $state([1, 2, 3]);
  let total = $derived.by(() => {
    let sum = 0;
    for (const n of numbers) sum += n;
    return sum;
  });
</script>

Two subtle behaviors worth knowing now:

  • Derived values are reassignable. As of Svelte 5.25, you can write likes += 1 to a $derived to do optimistic UI updates. The next time a dependency changes, it’ll snap back to the computed value. Use this carefully — it’s powerful, easy to overuse.
  • Reference equality short-circuits propagation. If a derivation returns a value that’s === to the previous value, downstream things don’t re-run. let large = $derived(count > 10) only notifies its readers when the boolean actually flips, not every time count ticks.

Effects: $effect

For when you need to do something when state changes — not compute a value, but cause a side effect.

<script>
  let count = $state(0);
  
  $effect(() => {
    document.title = `count: ${count}`;
  });
</script>

This runs once after mount and again whenever any tracked state it reads (count) changes. Like $derived, dependencies are picked up automatically.

Effects can return a teardown function that runs before the effect re-runs and when the component unmounts:

$effect(() => {
  const id = setInterval(() => count++, 1000);
  return () => clearInterval(id);
});

That’s the entire “lifecycle” you need for the common cases. onMount-style code becomes an $effect that runs once. Cleanup is the returned function.

The single most important rule about $effect: prefer not to use it. The official docs call it “an escape hatch.” If you find yourself writing $effect(() => { x = y * 2 }), you wanted $derived(y * 2). If you’re using two effects to keep two values in sync (a spent and a remaining), you wanted one $derived and a function binding. Section 8 returns to this — for now, just internalize: effects are for talking to the outside world, not for moving values around inside your component.

There’s also $effect.pre — same thing but runs before the DOM is updated. Use it when you need to read DOM state right before a change is committed (autoscroll-on-new-messages is the canonical example).

Components and props: $props

A child component receives data via $props():

<!-- Greeting.svelte -->
<script lang="ts">
  let { name, excited = false }: { name: string; excited?: boolean } = $props();
</script>

<h1>Hello {name}{excited ? '!' : '.'}</h1>
<!-- App.svelte -->
<script>
  import Greeting from './Greeting.svelte';
</script>

<Greeting name="Alice" excited />

Things to know:

  • $props() is called once, at the top of the script. Always destructure unless you have a strong reason to do let p = $props(); ... p.name.
  • Defaults, renaming ({ class: klass }), and rest props ({ a, b, ...rest }) are plain JavaScript destructuring. No new syntax.
  • Props are reactive by reference to the parent’s state. If the parent passes a $state value and updates it, the child sees the update.
  • Do not mutate props you don’t own. Reassigning a prop locally (like count = 5) overrides it temporarily — fine. Mutating an object property of a prop (obj.x = 5) will issue a runtime warning (ownership_invalid_mutation) and is a code smell. Communicate back to the parent via callback props or bindable props.

Two-way binding: $bindable

Sometimes you genuinely want a parent and child to share a piece of state. The pattern is:

<!-- TextInput.svelte -->
<script>
  let { value = $bindable() } = $props();
</script>

<input bind:value />
<!-- App.svelte -->
<script>
  import TextInput from './TextInput.svelte';
  let name = $state('');
</script>

<TextInput bind:value={name} />
<p>Hello, {name}!</p>

The child declares which props can be bound with $bindable(). The parent opts in with bind: syntax. Without $bindable(), the bind would be a compile error — Svelte 5 makes the contract explicit on both ends.

This is also the pattern for two-way binding on form elements: <input bind:value={name} />, <input type="checkbox" bind:checked={done} />, <select bind:value={selected}>. These are not magic — they expand into an onchange/oninput handler that updates the state.

Conditionals, loops, and template syntax

Svelte’s template directives are familiar if you’ve seen any HTML-templating language. They start with {# for blocks and {/ for endings.

{#if count > 10}
  <p>That's a lot.</p>
{:else if count > 0}
  <p>Some.</p>
{:else}
  <p>None.</p>
{/if}

{#each todos as todo, i (todo.id)}
  <li class:done={todo.done}>
    {i + 1}. {todo.text}
  </li>
{/each}

{#await fetchData()}
  <p>Loading...</p>
{:then data}
  <pre>{JSON.stringify(data)}</pre>
{:catch error}
  <p>Error: {error.message}</p>
{/await}

Three things worth noting from this snippet:

  • {#each ... as item, index (key)} — the parenthesized (todo.id) is the keyed expression, equivalent to React’s key prop. Without it, Svelte uses positional matching, which causes the same surprises React’s missing keys do. Use a stable unique identifier (like a database id) whenever your list can be reordered or filtered.
  • class:done={todo.done} — the class: directive conditionally adds a class based on a boolean. The newer class={...} syntax also accepts arrays and objects, but class:done is concise and ubiquitous.
  • {#await ...} — first-class async block. Renders the loading branch while the promise is pending, the :then branch with the resolved value, the :catch with the error. No need for useState/useEffect ceremony.

Other useful directives: {@html stringOfHtml} (inject raw HTML, dangerous in the usual way), {@const x = ...} (compute a value inside a block scope), {#key expression}...{/key} (force a subtree to be destroyed and recreated when expression changes — useful for animations and clearing component state).

Composing components: snippets

Slots are gone in Svelte 5. The replacement is snippets, which are more powerful because they’re parameterized.

A snippet is a chunk of markup with arguments, declared in any component:

{#snippet row(item)}
  <tr>
    <td>{item.name}</td>
    <td>${item.price}</td>
  </tr>
{/snippet}

<table>
  {#each items as item}
    {@render row(item)}
  {/each}
</table>

The killer use case is passing snippets as props to components. If you’ve used React’s render props, this will feel familiar.

<!-- Table.svelte -->
<script>
  let { data, row } = $props();
</script>

<table>
  {#each data as item}
    {@render row(item)}
  {/each}
</table>
<!-- App.svelte -->
<Table data={fruits}>
  {#snippet row(fruit)}
    <tr><td>{fruit.name}</td><td>{fruit.qty}</td></tr>
  {/snippet}
</Table>

When you write a snippet inside a component tag (like above), it implicitly becomes a prop with that name. Content that isn’t inside any {#snippet} becomes an implicit prop called children:

<!-- Button.svelte -->
<script>
  let { children, onclick } = $props();
</script>

<button {onclick}>{@render children()}</button>
<!-- Used elsewhere -->
<Button onclick={handleClick}>Click me</Button>

children is a function that you call (more precisely, render with {@render}). You can guard for optional snippets with {@render children?.()} or {#if children}...{/if}.

State outside components: .svelte.js files

If you have state that needs to be shared across multiple components — a global user session, a theme, a shopping cart — put it in a .svelte.js or .svelte.ts file:

// counter.svelte.ts
export const counter = $state({ count: 0 });

export function increment() {
  counter.count += 1;
}
<!-- some component -->
<script>
  import { counter, increment } from '$lib/counter.svelte';
</script>

<button onclick={increment}>{counter.count}</button>

Three things to know up front:

  1. The .svelte.js extension is required so the compiler knows to process runes in this file.
  2. You must not directly export a reassignable $state value. Export an object whose properties are reactive, export a class instance, or export getter functions — but export let count = $state(0) is broken across module boundaries because the compiler can’t transform references in importing files. See Section 7.
  3. No imports of writable from svelte/store for new code. That was the Svelte 4 way. It still works, but runes in .svelte.js files are the new pattern.

Classes with reactive fields

Classes are a clean way to package state and behavior. Svelte 5 supports $state (and $derived) in class fields:

// cart.svelte.ts
export class Cart {
  items = $state<Item[]>([]);
  total = $derived(this.items.reduce((s, i) => s + i.price, 0));
  
  add(item: Item) { this.items.push(item); }
  clear() { this.items = []; }
}

Two gotchas (Section 7 expands):

  • Class instances are not turned into proxies — the reactivity comes from the per-field $state. So instance.items.push(...) works (the array is a proxy), but instance = newInstance doesn’t make instance reactive on its own.
  • Methods lose this when used as event handlers: <button onclick={cart.clear}> will fail because clear is detached. Either use arrow-method fields (clear = () => { ... }) or wrap the call: <button onclick={() => cart.clear()}>.

Lifecycle and the DOM

Svelte 5 lets you express most lifecycle needs through $effect. But the imports from svelte are still there:

  • onMount(fn) — runs once after the component first mounts. fn can return a teardown.
  • onDestroy(fn) — runs when the component is unmounted.
  • tick() — returns a promise that resolves once pending DOM updates have applied. Awaiting tick() is how you read updated layout after a state change.

For DOM references, use bind:this:

<script>
  let canvas: HTMLCanvasElement;
  
  $effect(() => {
    const ctx = canvas.getContext('2d');
    // ... draw stuff
  });
</script>

<canvas bind:this={canvas} width="200" height="200"></canvas>

That’s most of what you need. The remaining template directives — transition:, in:/out:, animate:, use:, @attach, style: — are special-purpose; you’ll learn them when you need them.

What about SvelteKit?

The moment you need pages, routes, or anything server-side, you’re in SvelteKit territory. The mental model:

  • src/routes/+page.svelte is the page at /, src/routes/about/+page.svelte is /about, src/routes/blog/[slug]/+page.svelte is /blog/:slug. File-based routing.
  • +page.server.ts exports a load function that runs on the server and returns data the page can consume. Also exports form actions for handling POST requests without writing a separate API.
  • +server.ts defines API endpoints (GET, POST, etc.) that return Response objects. This is how you build a JSON API in the same project.
  • The whole thing builds to a deployment via an adapteradapter-node, adapter-static, adapter-vercel, adapter-cloudflare. The same source code can ship as a Node server, a static site, or an edge function depending on which adapter you install.

This document is about Svelte the component framework. SvelteKit deserves its own deep dive. Just know that “Svelte” alone is for components and “SvelteKit” is for whole apps, and the second one is what most people use in practice.


5. The Mental Model

Internalize these four ideas and almost everything else about Svelte will feel predictable. Skip them and you’ll spend a year being surprised by behaviors that, in retrospect, were obvious.

Core Idea 1: Svelte is a compiler, not a library. Everything else is downstream of that.

This is the difference that explains why Svelte’s API looks the way it does. React is a runtime library; it can’t know what your component does without executing it. So React’s API is shaped by what’s possible to discover at runtime: hooks are JavaScript function calls that follow rules (top-level only, consistent order, dependency arrays for closures). The “rules of hooks” exist because React has no compiler to enforce them.

Svelte does have a compiler. So Svelte’s API is shaped by what’s possible to enforce at build time. Runes look like function calls but aren’t — the compiler refuses to compile const myRune = $state; myRune(0). The compiler can put $state calls only where they make sense, refuse derivations that mutate state, transform count += 1 into the appropriate signal-write call, and emit errors in your editor before you run anything. This is why Svelte’s reactivity feels syntactically lighter than React’s: the compiler is doing the work that React’s runtime has to defer to you.

This predicts:

  • Bundle sizes are tiny. Most of the framework’s job happens at build time. The runtime helpers shipped to the browser are minimal — typically 10–20KB for a real app, versus 40+ for React.
  • Editor errors are unusually good. “You can’t use $state here” is something the compiler tells you, not a runtime warning you discover when something doesn’t work.
  • Two paradigms coexist. Old Svelte 4 code without runes still compiles — the compiler can tell which mode a file is in by looking for runes. This is great for migration and confusing for newcomers reading old StackOverflow answers.
  • There is no virtual DOM. Svelte doesn’t need one. The compiler already knows exactly which DOM nodes depend on which pieces of state, and emits the precise update code at the point where the state changes.
  • “It works in JS but breaks in Svelte” is rarely a real problem. Almost all valid JavaScript is valid inside a <script> block. The exotic things that don’t work tend to be cases where the compiler can’t transform a reference (re-exporting reassignable state from a module, for instance).

Core Idea 2: Reactivity is fine-grained. Components don’t “re-render.”

In React, when state changes, the entire component function re-runs from the top. The “fix” is useMemo, useCallback, React.memo. The mental model is: components are functions; state changes cause re-execution; you optimize by preventing unnecessary re-execution.

Svelte has no such concept. A Svelte component’s <script> runs exactly once when the component is instantiated. It does not re-run when state changes. Instead, the compiler has identified — at build time — every spot in your component where a piece of state is read. Each of those reads becomes a tiny update function. When state changes, only those specific update functions run.

A <h1>Hello {name}!</h1> doesn’t re-render an <h1>. It updates a single text node. If you change name, only that text node is touched — not the <h1>, not its parent, not anything else.

This predicts:

  • You don’t need useMemo or useCallback. There’s no “the function will be recreated on every render” problem because there’s no every-render. Functions defined in your <script> are created once.
  • There’s no key problem on the scale of React’s. Keys still matter for {#each} blocks (to align list items across updates), but you’ll never see “added a key to a useEffect dependency” as a debugging step.
  • Mutations are cheap. todos.push(newTodo) updates exactly the rendered list, not “the whole component tree under this component.”
  • $derived is lazy — it only recomputes when something actually reads it. If a derived value lives behind an {#if} that’s currently false, it does no work.
  • Effects run after the DOM updates, batched. Changing two $state values back to back triggers one effect run, not two.

Core Idea 3: Runes are compiler directives wearing function-call clothing.

A rune isn’t a function. It is a syntactic marker that tells the compiler “transform this in a specific way.” $state(0) compiles roughly to something like $.state(0) where $ is the internal Svelte runtime, and crucially, every reference to the resulting variable in this file is rewrittencount becomes $.get(count) when read and $.set(count, value) when written. This is why count can look like a plain number while still being reactive.

This predicts:

  • Runes only work in .svelte and .svelte.js/.svelte.ts files, because those are the files the compiler processes. Putting $state in a regular .ts file is a compile error.
  • You can’t pass state across module boundaries by directly exporting it as a reassignable let. The importing module isn’t getting compiled in a way that knows to wrap reads in $.get calls. The fix is to export an object (whose properties stay reactive thanks to the Proxy), or export getter functions.
  • You can’t destructure a $state object and have the destructured variables stay reactive. The compiler’s transformation works on references to the original binding, not on copies. let { count } = state evaluates state.count once and stores it.
  • Functions called from your component don’t automatically have access to component state. If you pass count as an argument, you’re passing the current number. To give a function reactive access, pass a getter: someFunction(() => count).

The mental shift: when you write count++ in a Svelte component, the compiler is rewriting that into a signal mutation. The line of source you see is not the line of JavaScript that runs.

Core Idea 4: Mutation is the idiom. Embrace it.

React taught a generation of developers that state must be immutable: spread, copy, replace. Svelte takes the opposite stance — and it’s correct for its model. When you wrap an object in $state, you get back a Proxy that watches every read and write. The point of the Proxy is to make mutation observable. If you treat the Proxy as immutable and reassign on every update, you’re paying for the Proxy machinery and ignoring its benefit.

<!-- Wrong, but won't break: -->
todos = [...todos, newTodo];

<!-- Right: -->
todos.push(newTodo);

Both work, but the first one is allocating a new array and reassigning the whole proxy, while the second is a single property notification through the existing proxy. At list size 5 the difference is invisible. At list size 5,000 the second one is hundreds of times faster.

This predicts:

  • You don’t need Immer. Svelte’s Proxy gives you the same “mutate freely, framework handles the bookkeeping” ergonomics, built in.
  • Array methods that mutate (push, splice, sort, reverse) are preferred over their non-mutating cousins. This is the opposite of React idiom.
  • Class-based state works naturally. Methods that mutate this fields are reactive without any special treatment. This is one of the cleanest patterns in Svelte 5 and a real differentiator from React.
  • Frozen objects, Object.create, and class instances behave differently. Anything that isn’t a plain object/array isn’t proxied; reactivity comes from the explicit $state fields inside it. If you wrap a class instance in $state(...), the wrapper does nothing. Put $state(...) on the fields instead.

6. The Architecture in Plain English

You can use Svelte productively without understanding what happens under the hood. But once you’ve hit your second weird bug, the under-the-hood picture saves real time. Here is what’s actually happening between you writing <button onclick={() => count++}>{count}</button> and the browser updating that text.

The compile step

When you save a .svelte file, Vite (the build tool SvelteKit uses) sees the change and asks the Svelte compiler to process it. The compiler parses the file into three pieces — script, template, style — and lowers each one into normal JavaScript and CSS.

The output looks roughly like this (simplified, real output is denser):

import * as $ from 'svelte/internal/client';

function App($$anchor) {
  let count = $.state(0);
  
  var button = root_template();  // a pre-built DOM template
  var text = $.child(button);
  
  button.__click = () => $.set(count, $.get(count) + 1);
  
  $.template_effect(() => $.set_text(text, `clicks: ${$.get(count)}`));
  
  $.append($$anchor, button);
}

Three things to notice:

  1. There is no virtual DOM. The compiler has built up a template of the static DOM structure (the <button>), reads it once at instantiation time, and inserts specific update calls ($.set_text) for the dynamic parts.

  2. Every read of count becomes a $.get(count) call. The “looks like a plain variable” abstraction is achieved by the compiler rewriting reads and writes. This is also why the abstraction breaks across module boundaries — a .ts file importing count doesn’t get this rewriting.

  3. The template_effect is the magic. It registers itself as a dependent of count (because reading $.get(count) inside a tracking context records the dependency). When count changes, this effect re-runs and updates only the text node.

The styles in the <style> block are processed separately. The compiler hashes the component (something like svelte-a3f9k2) and rewrites your CSS selectors to add the hash — button { color: red } becomes button.svelte-a3f9k2 { color: red } — and adds that class to every matching element in the template. That’s it. That’s how scoped CSS works. No CSS-in-JS runtime, no Shadow DOM, no postCSS plugin — just a class name added at build time.

The runtime: signals and the reactive graph

What Svelte ships to the browser is a small library of helpers ($.state, $.get, $.set, $.derived, $.template_effect, etc.) that implement a signals-based reactivity system. This is the same kind of system Solid.js made famous, generalized.

A signal is an object that holds a value plus a list of subscribers. Every $state(...) is backed by a signal. Every $derived(...) is backed by a derived signal — a signal that lazily computes its value from other signals. Every $effect(...) and every template_effect (the implicit ones the compiler emits for dynamic markup) is an effect — a function that’s registered to re-run when any signal it read has changed.

The reactive graph that emerges:

$state(count) ──► $derived(doubled) ──► template_effect ──► DOM text node

       └────────► $effect (your code)

When you mutate count, Svelte does two things, in order:

  1. Push (eager). Walk the dependency graph and mark every dependent as “dirty.” doubled is marked dirty. The template_effect is marked dirty. The $effect is queued to re-run.
  2. Pull (lazy, microtask later). A microtask is scheduled to flush. Inside the microtask, dirty effects are run in topological order. Each effect, when it runs, pulls the value of any signal it reads — which triggers recomputation of any dirty $derived it touches, recursively. After all updates have applied, user-defined $effects run.

This explains a lot of behavior:

  • Updates are batched. Setting count = 1; count = 2; count = 3 in the same tick causes the template_effect to run once, with count = 3. The intermediate values are never rendered.
  • $derived that nobody reads doesn’t run. It’s marked dirty, but the pull never happens.
  • $effect runs after the DOM updates, in a microtask. This is why you can’t read updated layout synchronously after a state change — you’d need to await tick() to wait for the microtask flush.
  • Reference equality short-circuits. If $derived(count > 10) is recomputed and the new value is true, same as before, the template_effect that reads it isn’t re-marked dirty.

Where state lives

For a component instance, all reactive state lives in signal objects created during the script’s one-and-only execution. Those signals are referenced by closures (the template_effects). When the component unmounts, those closures are torn down and the signals are eligible for garbage collection.

For state in a .svelte.js file, the signals live at module scope, which means they live as long as the module is loaded — effectively forever. This is how you get shared state across the app: import a .svelte.js module from multiple components, and they all see the same signals.

For Proxy-based deep reactivity, each property access on a state proxy potentially creates a per-property signal lazily. The Proxy is the bridge between “you wrote todos[0].done = true” and “the signal for that specific property fires.” You don’t see the per-property signals; they’re an implementation detail.

How a click flows through

Trace a button click in a counter component:

  1. User clicks the button.
  2. Browser fires a click event. (For most common events, Svelte uses event delegation — a single listener at the app root handles all clicks via a path walk. This reduces memory overhead.)
  3. Your onclick handler runs: count++. The compiler has transformed this to $.set(count_signal, $.get(count_signal) + 1).
  4. The signal’s value updates. Push: every dependent of count_signal is marked dirty. The template_effect rendering the text is now dirty. The <title> effect is queued.
  5. A microtask is scheduled.
  6. Event handler completes; control returns to the browser.
  7. Microtask runs. Dirty effects are pulled in order. The template_effect re-runs, reading the new count, updating the text node. The user-defined $effect runs, updating document.title.
  8. Browser paints the next frame with the new text.

The entire pipeline is the simplest case of what every reactive UI framework does. The difference is what’s compiled away versus what runs in the browser. In React, the whole virtual DOM diff happens at step 7. In Svelte, step 7 is a direct text-node update with no diff.


7. The Things That Bite You

These are the bugs and confusions that ate up real engineering hours for real teams. Each one connects back to the Mental Model above.

Gotcha 1: Destructuring strips reactivity

You’d expect that destructuring an object just creates aliases. You’d be wrong, in a very specific way.

<script>
  let todo = $state({ text: 'learn svelte', done: false });
  let { done } = todo;  // BAD — this is a plain boolean, captured once
  
  function toggle() {
    todo.done = !todo.done;  // updates todo, but `done` stays whatever it was
  }
</script>

<p>{done}</p>  <!-- never updates -->

Why: Destructuring is plain JavaScript. It reads todo.done once at the moment of destructuring, copies the value into a local done binding, and that binding has no connection to the reactive source. The compiler can rewrite reads of todo.done, but not reads of an unrelated local variable that happens to share a name.

Fix: Either don’t destructure, or destructure from a $derived:

let done = $derived(todo.done);  // reactive
// or just:
{todo.done}  // in the template

Same trap with $derived destructuring — but with the opposite outcome. let { a, b } = $derived(...) does keep a and b reactive, because the compiler treats this destructuring specially. So destructuring $state: strips reactivity. Destructuring $derived: preserves it. This inconsistency is one of the more common stumbles.

Gotcha 2: Class methods lose this when used as event handlers

<script>
  class Counter {
    count = $state(0);
    increment() { this.count++; }
  }
  let c = new Counter();
</script>

<button onclick={c.increment}>+</button>  <!-- "this is undefined" at click time -->

Why: This is regular JavaScript — assigning c.increment to a property detaches the method from its receiver. Has nothing to do with Svelte specifically, but it’s the most common stumble when people first try class-based state.

Fix: Wrap the call, or define the method as an arrow-function field.

<button onclick={() => c.increment()}>+</button>
<!-- or -->
class Counter {
  count = $state(0);
  increment = () => { this.count++; };
}

Gotcha 3: Proxies don’t survive structuredClone, postMessage, or IndexedDB

This one cost a real production team a lot of hours and made it to GitHub issues and HackerNews. If you wrap an object in $state and then try to save it to IndexedDB (or send it to a web worker via postMessage, or structuredClone it), you get:

DataCloneError: Failed to execute 'put' on 'IDBObjectStore':
#<Object> could not be cloned.

Why: IndexedDB serializes via the structured clone algorithm, which doesn’t know how to clone Proxy objects. Anything Proxy-based runs into the same wall. This includes deep state — even if your top-level state isn’t a proxy, properties of a proxy that themselves contain objects become proxies recursively.

Fix: Use $state.snapshot(value) to take a plain-object deep copy before handing data to anything that does structured cloning:

await db.put('users', $state.snapshot(user));
worker.postMessage($state.snapshot(state));

This is the kind of behavior the docs warn about but you only really learn by hitting it. Make a habit of $state.snapshot-ing on any boundary between your app and anything that does serialization.

Gotcha 4: You can’t directly export reassignable state from a module

// counter.svelte.ts — DOES NOT WORK
export let count = $state(0);
export function increment() { count += 1; }
// in some component
import { count } from './counter.svelte';
console.log(count);  // logs '0' — but never updates!

Why: This is Mental Model 3 biting you. The Svelte compiler rewrites references to count within counter.svelte.ts to call into the signal machinery, but it can’t rewrite references inside files it doesn’t know are importing this. The cross-module read just gets the initial value at import time.

Fix: Either wrap in an object (don’t reassign the binding, mutate its properties), or expose getter functions:

// Option A — object you mutate
export const counter = $state({ count: 0 });
export function increment() { counter.count += 1; }
// Option B — module-local state, getter function
let count = $state(0);
export function getCount() { return count; }
export function increment() { count += 1; }

Option A is the idiomatic one and what you’ll see in most Svelte 5 codebases.

Gotcha 5: Mutating props you don’t own emits a warning

<!-- Child.svelte -->
<script>
  let { user } = $props();
</script>

<button onclick={() => user.name = 'Alice'}>rename</button>

If user is reactive state from the parent, this works — the parent sees the change — but you get a ownership_invalid_mutation runtime warning. The component is reaching across its boundary to modify state it doesn’t own, and Svelte is rightly suspicious.

Why: Mutating props you don’t own creates spooky-action-at-a-distance bugs that are hard to trace. Two siblings can be writing to the same parent state, and now the parent has no clean point at which to observe changes or roll them back. Svelte treats this as an antipattern.

Fix: Either accept a callback prop and let the parent do the mutation (onRename: (name: string) => void), or — if the value really is co-owned — make it explicitly $bindable.

Gotcha 6: $effect for state-syncing is an infinite-loop generator

The textbook anti-pattern from the official docs:

<script>
  const total = 100;
  let spent = $state(0);
  let left = $state(total);

  // DON'T do this
  $effect(() => { left = total - spent; });
  $effect(() => { spent = total - left; });
</script>

Why: Each effect reads one state and writes another. The write triggers the other effect, which writes back. You get either an infinite loop or, more likely, “it works but feels weird and I don’t know why.” Effects are for side effects, not for relationships between values.

Fix: Use $derived for the dependent value, and accept that one direction is computed and one is the source of truth:

let spent = $state(0);
let left = $derived(total - spent);

If you need to be able to type into the “left” input directly, use a function binding that translates the input back to the source:

<input bind:value={() => left, (newLeft) => spent = total - newLeft} />

Function bindings are one of Svelte 5’s quieter features but they’re the right answer to “I have two views of the same conceptual thing.”

Gotcha 7: $effect does not track values read after await

$effect(async () => {
  const data = await fetch(...);
  // anything you read here is NOT a dependency of this effect
  console.log(otherState);  // changes to otherState won't re-run the effect
});

Why: Dependencies are tracked synchronously, by recording reads during the function’s synchronous execution. By the time await resumes, the tracking context has been torn down. This is true of every signals-based system, not just Svelte, but it surprises people coming from React’s useEffect where the dependency array is explicit.

Fix: Either read everything synchronously at the top of the effect (force tracking) or use $effect only for the fire-and-forget side and put downstream reactivity in a $derived.

Gotcha 8: Whitespace handling between tags

Svelte 5 collapses whitespace between elements aggressively, in a way that can change layout. If you have <a>...</a> <a>...</a> and depend on the single space between them for visual separation, the compiler may collapse that. The fix when this bites you is &nbsp;, or explicit margins. Mentioned in the migration guide as a Svelte 5 change.

Gotcha 9: Touch events are passive by default

ontouchstart/ontouchmove handlers are attached as passive listeners for performance. This means you can’t event.preventDefault() inside them. If you really need to (rare — usually for custom scroll behavior), you have to attach the listener manually using the on import from svelte/events and pass passive: false. Don’t do this lightly.

Gotcha 10: Old Svelte tutorials are still everywhere and they’re wrong now

If you Google “Svelte reactive store” or “Svelte tutorial 2023” you’ll find content about $:, export let, <slot>, on:click, and createEventDispatcher. All of that is Svelte 4 syntax. It still works, but it’s legacy. Don’t learn it; learn the runes-based Svelte 5 approach. The official docs sidebar has a “Legacy APIs” section that’s explicit about what’s old.

The fast filter: if a tutorial uses $state or $derived, it’s Svelte 5. If it uses $: or export let, it’s Svelte 4. Treat the latter like a museum piece.


8. The Judgment Calls

These are the decisions that separate someone who’s read the docs from someone who’s shipped Svelte for two years.

When to reach for $effect vs $derived vs a function

The hierarchy, easiest to hardest:

  1. Can I express this as a $derived? If the answer is “I want a value computed from other values,” it’s a $derived. Stop.
  2. Can I express this as a function called in the template? Some things don’t need to be “reactive state” at all — just a pure function that runs when the template evaluates. {formatPrice(item.cost)} doesn’t need any rune.
  3. Do I need to react to a state change by doing something external (analytics, DOM, third-party library)? Then $effect.
  4. Do I need to keep two pieces of state in sync? Stop. You wanted a $derived or a function binding.

The mental check: $effect exists to integrate with the outside world. If both ends of your effect are inside your component, you probably don’t need an effect. The HN comment that nailed this: “the more Svelte 5 I write, the fewer effects I use.”

When to use a class vs an object for state

Both work. Both are idiomatic. The split:

  • Use an object for state that’s mostly data with a few helper functions. Lighter, simpler, easier for TypeScript. Most “stores” in the colloquial sense.
    export const cart = $state({ items: [], total: 0 });
    export function addItem(i) { cart.items.push(i); }
  • Use a class when state and behavior are tightly coupled and you’ll have multiple instances of the same shape. The reactive-field syntax (field = $state(...)) is genuinely clean, and $derived fields are a great way to express invariants.
    export class Counter {
      count = $state(0);
      isLarge = $derived(this.count > 10);
      increment = () => { this.count++; };
    }

The signal: do you ever need two of these? Two carts, two counters, two form-state objects? Then it’s a class. One global config? It’s an object.

When to use $state.raw vs default $state

Default is deeply reactive — every property of an object, every element of an array, is tracked. $state.raw is opt-out: the whole value is one signal that fires only on reassignment.

Use $state.raw when:

  • The value is a large array or object you treat as immutable anyway (the canonical case: a list of 10,000 search results that gets replaced wholesale on every query).
  • The value is structurally complex in ways the Proxy mishandles (custom classes with internal state, observables from another library).
  • You’re interfacing with code that doesn’t tolerate Proxies (some graphics libraries, some IndexedDB adapters, some validators). $state.raw is cheaper than wrapping in $state.snapshot on every access.

Default $state is right almost always for hand-built objects you intend to mutate. The Proxy isn’t free, but the cost is on the order of nanoseconds per property access — invisible for normal UI work.

When to use SvelteKit vs plain Svelte

Almost always SvelteKit. Plain Svelte alone is right when:

  • You’re building a component library that other apps will consume.
  • You’re embedding Svelte components in an existing app that has its own router (Astro islands, a non-Svelte SPA, server-rendered pages with sprinkles of interactivity).
  • You’re building a web component to ship as an embeddable widget.

For anything that looks like a website or an app — pages, navigation, server-side data fetching, SEO, deployment — SvelteKit is the answer. The framework is intentionally designed so you can’t get the same “ergonomics floor” without it. If you find yourself building a router on top of plain Svelte, stop and use SvelteKit. The reason is the same reason you don’t write Express from scratch instead of using Next.js: someone has already solved this and their solution is better than yours will be.

When to use stores (legacy) vs runes in .svelte.js

If you’re starting today: use runes in .svelte.js files. Always.

If you’re maintaining a Svelte 4 codebase: keep stores until you have a reason to migrate. They work. The interoperability is good. Mass migration for its own sake is busy work.

If you’re using a library that exposes stores: import { get } from 'svelte/store' to read once, or use the $ prefix in components to auto-subscribe. Bridges between stores and runes are well-trodden; libraries that haven’t migrated yet still play nicely.

The reasons to prefer runes over stores: better TypeScript inference, no .subscribe/.set/.update boilerplate, the same syntax works inside and outside components, derived state behaves the same way, and the team is gradually moving the entire ecosystem here.

When to use bind: vs a callback prop

Both let a child component update a value the parent cares about. The split:

  • bind:value (with $bindable() in the child) when the value is the state — form inputs, toggles, sliders, anything where the child component conceptually owns the editing but the parent owns the data.
  • Callback props (onChange={(v) => ...}) when the parent needs to do something more than just store the new value — validation, debouncing, calling an API, derived updates.

The bind path has less ceremony for trivial cases (<DatePicker bind:value={date} /> is just so clean). The callback path is more explicit and easier to follow when something is happening on change. Default to bindings for primitive form-like values and callbacks for everything else.

When to drop down to actions (use:) or attachments (@attach)

When you need to wire up imperative library code to a DOM element. Examples:

  • A charting library that needs a DOM element handed to it (Chart.js, D3 selections that don’t play nice with declarative templates).
  • Tooltips, dropdowns, modals from headless libraries that need to register click-outside handlers.
  • Auto-resize textareas, infinite scroll, intersection observers.

Actions take an element and return { update?, destroy? }. Attachments (newer, @attach) are a cleaner version of the same idea. Either way, the goal is the same: keep imperative DOM work neatly attached to the element it’s about, with automatic cleanup.

You’ll know it’s time to reach for one when you find yourself using bind:this plus an $effect to manually attach and detach event listeners. That’s the smell. An action puts the same logic in a reusable, lifecycle-aware container.

When to migrate from Svelte 4

Three answers, in order of how often they apply:

  1. When the codebase is small (one developer, a few thousand lines), run the automigration script and move on. The script handles the 80% mechanical changes (export let$props, $:$derived/$effect, on:clickonclick, <slot>{@render children()}). What’s left is small.
  2. When the codebase is medium-large, migrate incrementally. Svelte 5 supports mixing Svelte 4 and Svelte 5 components in the same project. Migrate one route at a time, one component at a time. Don’t try to do it in one PR.
  3. When the codebase is huge and has third-party Svelte 4 dependencies that haven’t migrated, wait. The compatibility is good enough that you can stay on Svelte 5 with Svelte 4 components for a long time. Force a migration only when you’re paying a real cost (you need a Svelte 5 feature, performance, types, etc.).

When to prefer Svelte over React for a new project

Honest, opinionated, no hedging:

  • Pick Svelte if your team values syntax economy and tight bundles, your app is interaction-heavy but not under big-company hiring constraints, and you’re comfortable being slightly off the well-trodden path. Animation-heavy UI is a real Svelte strength.
  • Pick React if you need the largest possible hiring pool, you’ll integrate many ready-made libraries (rich-text editors, drag-and-drop kits, charting, design systems), or your organization has standardized on React and the cost of being different is higher than the win.
  • Pick neither — use HTMX or vanilla if your app is mostly server-rendered pages with sprinkles of interactivity. Svelte is great for that too (especially with SvelteKit), but at low-interactivity levels the case for any reactive framework weakens.

This decision is more about organizational fit than technical merit. Svelte 5 is technically excellent. React’s ecosystem is larger. Both are real considerations.


9. The Commands/APIs That Actually Matter

Grouped by task. The Distilled Introduction taught these in context; this is the quick-reference version with the flags experienced users reach for.

The runes — your 90%

RunePurposeWhen to reach for it
$state(value)Reactive state. Returns a Proxy for objects/arrays.Default. Use everywhere you have changing data.
$state.raw(value)Reactive state without deep proxy.Large structures, immutable update style, Proxy-allergic libraries.
$state.snapshot(value)Plain non-reactive deep copy.Crossing serialization boundaries: IndexedDB, postMessage, JSON to APIs that misbehave.
$derived(expr)Lazy computed value.Any value derived from other state. Default choice over $effect.
$derived.by(() => { ... })Multi-statement derived.When the derivation needs multiple lines.
$effect(() => { ... })Run side effect on state change.Talking to non-Svelte code, third-party libs, manual DOM work. Avoid for internal sync.
$effect.pre(() => { ... })Effect that runs before DOM updates.Autoscroll, layout measurements pre-change.
$effect.root(() => { ... })Manually managed effect scope.Effects outside a component (rare).
$props()Receive props in a component.Every component that takes props. Destructure it.
$bindable(default?)Mark a prop as bindable from the parent.Two-way binding for custom components.
$inspect(value).with(fn)Dev-time debugging. Logs when a value changes.Investigating “why is this re-rendering / not re-rendering.” Stripped in production.
$props.id()Generate a unique-per-component ID.Linking labels to inputs, ARIA references, server-rendered with hydration consistency.
$host()Reference the host element.Custom elements only.

Imports from svelte

import { onMount, onDestroy, tick, untrack } from 'svelte';
  • onMount(fn) — runs after the DOM is attached. Equivalent to a one-shot $effect, but you’ll see this in older code and it’s fine to use.
  • onDestroy(fn) — cleanup on unmount.
  • tick() — returns a Promise that resolves after pending state changes have flushed to the DOM. Use to read post-update layout: state++; await tick(); element.offsetHeight.
  • untrack(fn) — read state inside fn without registering a dependency. Use inside $effect when you want to read a value but not re-run when it changes.

Reactive built-ins from svelte/reactivity

import { SvelteMap, SvelteSet, SvelteDate, SvelteURL } from 'svelte/reactivity';

Reactive versions of Map, Set, Date, URL. Use these instead of the built-ins when you want reactivity on them. Same API, drop-in replacements.

Template syntax — the directives you’ll use weekly

{expression}                       Interpolate a value.
{#if cond}...{:else if c}...{/if}  Conditional rendering.
{#each list as item, i (item.id)}  Keyed loop. Always provide the key.
{#await promise}...{:then v}...    Async render.
{#key expr}...{/key}               Force destroy/recreate when expr changes.
{#snippet name(args)}...{/snippet} Reusable markup chunk.
{@render snippet(args)}            Render a snippet.
{@html htmlString}                 Inject raw HTML. XSS risk if unsanitized.
{@const x = expr}                  Local binding inside a block.

bind:value={state}                 Two-way binding (form inputs, $bindable props).
bind:this={el}                     DOM ref.
bind:group={value}                 Radio/checkbox group binding.

class:active={isActive}            Conditional class.
style:color={color}                Reactive inline style.
use:action                         Attach an action.

transition:fade                    Transition on enter and leave.
in:fade / out:fade                 Asymmetric transitions.
animate:flip                       FLIP animation in a keyed each.

onclick={handler}                  Event handler (standard DOM prop).
{...spreadProps}                   Spread attributes/props.

Special elements

  • <svelte:window onresize={...}> — bind events on window.
  • <svelte:document>, <svelte:body> — same for those.
  • <svelte:head> — content to inject into <head>. SEO/meta lives here.
  • <svelte:element this={tag}> — dynamic tag name. Use when the tag depends on data.
  • <svelte:boundary onerror={...}> — error boundary. Newer Svelte 5; catches errors from descendant components.

Dev tools

  • Svelte Inspector — Vite plugin that lets you click an element in the browser to jump to its source. Install @sveltejs/vite-plugin-svelte-inspector. Indispensable for navigating large apps.
  • Browser DevTools Svelte tab — community extension. Shows component tree and state. Less polished than React DevTools but functional.
  • $inspect(value).with((type, value) => console.log(type, value)) — programmatic state-change logging. Stripped from production builds. Add temporarily when you’re chasing “why isn’t this updating.”
  • svelte-check — CLI tool that runs type-checking and Svelte-specific validation across your project. Run it in CI: npx svelte-check.

10. How It Breaks

When something goes wrong in a Svelte app, the failure modes cluster into a small number of categories. Knowing the catalog cuts debugging time dramatically.

”Why isn’t my UI updating?”

The most common bug. The state changed, the template should have updated, it didn’t. The debugging checklist, in order:

  1. Is the value actually a $state? If it’s a plain let count = 0, it’s not reactive. (Svelte 5 will warn for top-level let that’s written to, but it’s still easy to miss.)
  2. Did I destructure it? let { count } = state strips reactivity. Use state.count directly or $derived.
  3. Did I read it through a function call? Passing count as an argument passes the current value, not a reactive reference. Pass () => count if you need reactive access in another function.
  4. Is it across a module boundary as a directly exported let? Can’t work. Use the object-export pattern.
  5. Am I mutating something that isn’t actually a proxy? Class instances, frozen objects, Object.create-created objects, and proxies whose source isn’t $state won’t trigger updates on mutation.
  6. Is the read happening inside a tracking context? Reads in event handlers and non-effect functions don’t register dependencies. If something needs to react, the read has to be in a $derived, $effect, or template expression.

The fastest tool: $inspect(thing).with(console.log) on the value you expect to be reactive. If it doesn’t log when you mutate, the value isn’t reactive in the way you think.

”Why is my effect running too often (or in an infinite loop)?”

  1. Are you reading and writing the same state inside the effect? Classic loop. Use untrack(() => something) for the read, or — better — refactor to a $derived.
  2. Is the effect reading an object reference rather than a property? $effect(() => { someObj; }) runs whenever someObj is reassigned, not when its properties change. To depend on a property, read it explicitly.
  3. Are you re-creating dependencies? $effect(() => someFn({ x: count })) allocates a new object each run. If someFn stores it in state somewhere, you get loops.
  4. Multiple effects keeping things in sync? Anti-pattern. Refactor to $derived or function bindings.

”It works in dev but breaks in production”

Most common cause: SSR/hydration mismatch. Svelte renders your component on the server (in SvelteKit), then takes over on the client. If the server-rendered HTML doesn’t match what Svelte expects on hydration, things go wrong. Typical causes:

  • Reading window, document, or other browser globals during initial render. Wrap in if (browser) (imported from $app/environment) or move to onMount/$effect.
  • Random values or Date.now() in initial state — different server and client values.
  • A $effect that runs synchronously during server render and modifies state (effects don’t run on the server, but if your script does it on the client too, the order can differ).

Debugging: disable JavaScript in DevTools, reload, see what the server rendered. Compare to the live page.

Second most common: a value that was reactive in dev tests stopped being reactive in production. The compiler does more aggressive optimization in production builds — usually fine, sometimes uncovers latent bugs in code that relied on accidental re-renders.

”Why does TypeScript yell at me about runes?”

  • Cannot find name '$state' — TypeScript doesn’t see the rune declarations. Make sure your tsconfig.json is the one generated by SvelteKit (it has the right reference paths) and that the Svelte extension is installed in your editor.
  • Type 'X' is missing the following properties from type 'Y' — almost always a $props typing issue. Make sure you annotated the destructured props or used an interface.
  • Snippet typing errorsSnippet<[Param1, Param2]> is a tuple, not a positional list. Snippet<Item> is wrong; Snippet<[Item]> is right.

”The runtime warning about ownership”

ownership_invalid_mutation. You’re mutating a prop that came from a parent. See Gotcha 5 above. Either don’t mutate (use a callback prop) or use $bindable to make the contract explicit.

”My third-party library doesn’t work”

If the library expects to receive plain JavaScript objects and you pass it a $state proxy, it may misbehave — particularly for libraries that do structuredClone, JSON.stringify with custom replacers, or any kind of reference comparison across libraries. The fix is almost always $state.snapshot(...) at the boundary.

If the library exposes a store (Svelte 4 style) and you want to consume it in a component, use the $ prefix: $myStore. To turn a store into a rune-friendly value: const value = $derived($myStore). To go the other way (a rune you want exposed as a store), there are utility functions in the ecosystem (toStore, fromStore).

General debugging workflow

When something’s wrong and you’re not sure where to start:

  1. $inspect the suspect values to confirm what’s actually reactive.
  2. Use the Svelte Inspector to click your way to the right file.
  3. Open the browser DevTools and check the Console for runtime warnings — Svelte issues a lot of *_warning_* messages with descriptive names like ownership_invalid_mutation, state_unsafe_mutation, hydration_mismatch. They tell you what’s wrong.
  4. If you suspect a build issue, look at node_modules/.vite / the Vite dev-server output. Sometimes a stale cache is the issue (rm -rf node_modules/.vite && npm run dev).
  5. Reproduce in the Svelte Playground — if the bug doesn’t reproduce there, the issue is in your build, not your Svelte code. If it does, you have a minimal repro to file or share.

11. The Downsides / Disadvantages

The honest costs of choosing Svelte. None of these are dealbreakers across the board, but pretending they don’t exist is the surest way to regret the choice later.

Downside 1: The ecosystem is meaningfully smaller than React’s

This is the unavoidable headline. NPM downloads, library counts, hireable engineers, Stack Overflow answers, AI assistant training data — every measurable proxy says React has 5–15× the ecosystem mass of Svelte. As of 2026 Svelte is healthy, growing, and not going away, but it isn’t going to overtake React on these axes in the timeframe of your career.

Where it comes from: Network effects compound. React’s head start, Facebook’s backing, and “no one ever got fired for choosing React” in corporate environments have built a self-reinforcing ecosystem that’s hard to dent regardless of technical merit.

What it costs: You’ll bump into “there’s no good Svelte version of this React library” once every project or so. Date pickers, rich text editors, drag-and-drop kits, virtualized table libraries, design systems, charting libraries with millions of downloads — most exist for Svelte but in less-polished form, or as community ports a version behind. Reach for them and you’ll find rough edges that the React versions have sanded away. For most projects this is annoying; for niche or specialized projects it can be a real blocker. Hiring is the other concrete cost — the pool of Svelte-experienced developers is small, especially in enterprise markets.

When it’s a dealbreaker: Enterprise environments with strict hiring constraints, projects that require a specific complex library available only for React, or teams where most members will be junior contractors unfamiliar with the framework. When you can live with it: Most product startups, internal tools, side projects, and teams where the senior engineers can ramp the rest up.

What people think mitigates it but doesn’t: “Svelte uses standard web tech, so any developer can pick it up in a day.” The syntax surface is small, yes. The mental model and ecosystem-fluency aren’t. A senior React engineer joining a Svelte codebase will be productive in a week and excellent in a few months — not “a day.”

Downside 2: Two paradigms now coexist in the codebase

Svelte 4 syntax (legacy mode: let count = 0, $:, export let, <slot>) and Svelte 5 syntax (runes) both compile. The team made the explicit decision to support backwards compatibility, and they were right to — forcing the world to migrate would have been a disaster — but it’s a real cost to learners.

Where it comes from: A major paradigm shift that the team correctly chose to soften with a migration window.

What it costs: Tutorials, blog posts, AI-generated code, and Stack Overflow answers all mix the two paradigms freely. Beginners hit conflicting advice constantly: “use $:” / “use $derived,” “use <slot>” / “use {@render children()},” “use createEventDispatcher” / “use callback props.” The official docs have a clear “Legacy APIs” section, but the broader web doesn’t. You’ll waste time learning Svelte 4 patterns from old tutorials before you realize they’re not the way anymore.

When it’s a dealbreaker: Rarely. When you can live with it: Always, by sticking to the most recent official docs and ignoring anything dated before late 2024.

Downside 3: The compiler is magic, and magic has costs

Svelte’s compiler does a lot. It rewrites every count++ into signal calls, builds the reactive dependency graph from your code, optimizes templates into pre-built DOM structures, scopes your CSS, and inserts hydration markers. When it works, it works beautifully. When it doesn’t, the gap between “the source I wrote” and “the JavaScript that ran” is significant, and debugging across that gap is harder than in React (where what you wrote is what’s running).

Where it comes from: The defining design choice. The whole framework’s value proposition rests on compiler smarts.

What it costs: When something behaves weirdly, you sometimes have to read the compiler output (visible in the Playground “JS Output” tab, or in .vite/deps) to figure out why. Stack traces sometimes point into the Svelte runtime, not your code. Source maps are good but not perfect. Reactive context bugs — where you can’t tell whether a read registered a dependency or not — are real and require understanding what the compiler did.

When it’s a dealbreaker: For teams that need full traceability between source and runtime (some regulated industries, debugging-heavy domains). When you can live with it: Most product teams, where the rare deep dive is worth the daily ergonomic win.

Downside 4: The Proxy-based deep reactivity has structural costs

$state of an object returns a Proxy. Every property access goes through the Proxy. This is fast enough for normal UI — nanoseconds per access — but it’s not free, and it creates incompatibility with anything that doesn’t expect Proxies.

Where it comes from: The choice to enable mutation as the primary update idiom. To make todos.push(...) work, you need a Proxy that observes the push. There is no zero-cost version of this.

What it costs: Three concrete things. (1) Performance: in hot paths processing very large data structures (10,000+ items, animations driven by frequent property writes), the Proxy overhead is measurable. Most apps don’t hit this; some do. (2) Serialization compatibility: structuredClone, JSON.stringify with replacer functions, IndexedDB, postMessage, third-party libraries doing reference equality — all can break when handed a Proxy. (3) Cognitive load: you now have to know when a value is a Proxy versus when it isn’t, and how to detect the difference (which is itself non-trivial — typeof proxy === 'object' returns 'object', exactly like an unwrapped object).

When it’s a dealbreaker: Apps that integrate deeply with libraries hostile to Proxies (some IndexedDB ORMs, some real-time data libraries), or apps with very large reactive datasets. When you can live with it: Most apps, especially with judicious use of $state.raw and $state.snapshot.

Downside 5: The hiring pool is small, especially outside SF/Berlin

Svelte talent is real, but concentrated. Posting a Svelte job in most cities returns far fewer applicants than the same job in React. Senior Svelte engineers in particular — people who’ve shipped at scale, debugged real production issues, made architectural decisions — are scarce. You’ll either retrain React engineers (which works, but takes months) or pay a premium for the few existing Svelte specialists.

Where it comes from: Market dynamics. Engineers learn what gets them jobs. React gets them jobs.

What it costs: Slower hiring, longer ramp-up for new joiners, higher risk if your one Svelte specialist leaves. For startups this is manageable; for larger orgs the math gets uncomfortable.

Downside 6: Performance benefits are real but often overstated

Svelte’s small bundle and direct-DOM updates produce genuine performance wins, but the win is most visible in bundle size and startup time. For steady-state performance of a running app, modern React with proper memoization, modern Vue with its compiler, and Solid all perform comparably to Svelte. The benchmarks where Svelte dominates are usually micro-benchmarks (synthetic counter apps, TodoMVC clones) where the framework overhead is a large fraction of total work.

Where it comes from: Marketing focus on benchmarks rather than typical production performance.

What it costs: If you choose Svelte expecting an order-of-magnitude steady-state speedup over a well-tuned React app, you’ll be disappointed. The real wins are: smaller bundles (a 2–4× advantage that compounds over time and shows on slow networks), simpler reactivity (fewer “why is this re-rendering” debugging sessions), and the absence of a virtual DOM tax (which mainly matters on low-end devices).

Downside 7: SvelteKit’s design choices won’t suit every backend

SvelteKit is opinionated. File-based routing, server load functions, form actions, type-generated route APIs. If your backend looks like its model — a Node/edge environment, JSON over HTTP, server-rendered HTML with progressive enhancement — it’s a delight. If your backend is a heavy Java/Go API with strict GraphQL contracts and your frontend is mostly client-rendered, SvelteKit’s server-rendering features add complexity you don’t need, and you may find yourself fighting the framework’s defaults.

Where it comes from: The framework has a thesis about how the modern web works (forms over fetch, server-rendered first, progressive enhancement). It’s a good thesis. It isn’t the only one.

What it costs: You either align with SvelteKit’s view of the world or you find yourself doing things “the hard way” — disabling SSR per route, building API endpoints that don’t follow conventions, writing custom load logic that fights the framework. Most teams align happily; some don’t.

Downside 8: Things move fast and you’ll feel it

Svelte 5 was a substantial rewrite that landed in 2024. The ecosystem is still settling around it. Smaller framework releases (snippets-replacing-slots, attachments coming in alongside actions, $state.eager added in 2025, await in components arriving recently) keep shipping. This is mostly good — the framework is improving — but if you want a quiet “set it and forget it” framework with a stable surface for the next five years, Svelte isn’t quite that yet. React-the-API has been roughly stable for years; Svelte-the-API is still evolving.

When it’s a dealbreaker: Codebases with very long maintenance horizons and small teams who can’t dedicate time to staying current. When you can live with it: Anything where you’re shipping features actively. The team’s track record on backwards compatibility is good, but you’ll still be reading release notes more than you would for React.


12. The Taste Test

A taste-test side-by-side. Same component, beginner version on the left, experienced-engineer version on the right.

Beginner version

<script>
  import { onMount } from 'svelte';
  
  let users = $state([]);
  let loading = $state(true);
  let error = $state(null);
  let search = $state('');
  let filtered = $state([]);
  
  onMount(async () => {
    try {
      const res = await fetch('/api/users');
      users = await res.json();
      filtered = users;
    } catch (e) {
      error = e.message;
    } finally {
      loading = false;
    }
  });
  
  $effect(() => {
    if (search === '') {
      filtered = users;
    } else {
      filtered = users.filter(u => u.name.includes(search));
    }
  });
</script>

<input bind:value={search} placeholder="search" />

{#if loading}
  <p>Loading...</p>
{:else if error}
  <p>Error: {error}</p>
{:else}
  <ul>
    {#each filtered as user}
      <li>{user.name}</li>
    {/each}
  </ul>
{/if}

Experienced version

<script lang="ts">
  type User = { id: string; name: string };
  
  let search = $state('');
  let usersPromise = $state<Promise<User[]>>(loadUsers());
  
  function loadUsers(): Promise<User[]> {
    return fetch('/api/users').then(r => {
      if (!r.ok) throw new Error(`Failed: ${r.status}`);
      return r.json();
    });
  }
</script>

<input bind:value={search} placeholder="search" />

{#await usersPromise}
  <p>Loading...</p>
{:then users}
  {@const filtered = search 
    ? users.filter(u => u.name.toLowerCase().includes(search.toLowerCase())) 
    : users}
  <ul>
    {#each filtered as user (user.id)}
      <li>{user.name}</li>
    {/each}
  </ul>
{:catch error}
  <p>Error: {error.message}</p>
{/await}

What changed and why:

  • Replaced loading/error/users with {#await ...}. Three pieces of state collapsed into one promise. The template tells you exactly what state the async work is in. No effect to coordinate them.
  • Replaced the filter $effect with a {@const} inside :then. Filtering is a derivation, not a side effect. Doing it inline keeps the data flow visible — you can read the template and see exactly what’s rendered for each state.
  • Added a key (user.id) to the {#each}. Always do this. Without it, list edits cause unnecessary DOM churn and bind:this references shuffle.
  • Used lang="ts" and typed the data. No reason not to. The types catch the wrong field access at compile time.
  • Case-insensitive search. A real product feature the beginner version missed. Not a Svelte thing per se, but the kind of thing experienced engineers add.

Code review red flags

If you’re reviewing Svelte code, these patterns should make you pause:

  • Multiple $effects in one component, especially ones that assign to other state. Almost always wrong — you wanted $derived or function bindings.
  • $state used for a value that’s computed from other state. Should be $derived.
  • Long destructuring of $state objects. Either accidentally non-reactive or unnecessarily reactive. Read directly from the source or use $derived.
  • bind:value without $bindable() in the child. Compile error, but also a sign the contract between parent and child isn’t clear.
  • {#each list as item} without a key. Either intentional (the list never reorders) or a bug. Comments should clarify.
  • Mutating a prop in the child component. Either use a callback or $bindable. Don’t mutate someone else’s state.
  • onMount(() => { /* async stuff */ }) that doesn’t handle cleanup. $effect with a returned teardown is cleaner.
  • {@html userInput} anywhere. XSS waiting to happen unless rigorously sanitized upstream.
  • A component file longer than ~400 lines. Not a hard rule, but Svelte’s strong support for snippets means there’s almost always a good way to factor.
  • Manually constructing event objects or using createEventDispatcher. Svelte 4 style. Migrate to callback props.
  • Stores (writable, readable) in new code. Probably fine, but ask whether a .svelte.js file with runes wouldn’t be cleaner.

What good Svelte code looks like

  • State is declared once, near the top, and clearly named. Most components have 1–5 $state declarations. More than that suggests too many responsibilities.
  • $derived is used liberally for any computed value. No useMemo-style premature optimization needed; derived values are cheap.
  • $effect appears rarely — typically zero or one per component, used to integrate with an external system.
  • Components are small. A typical Svelte component is 30–80 lines. The framework rewards composition.
  • Snippets are used wherever the same markup repeats with different data. Especially for table rows, list items, repeated form sections.
  • <style> blocks contain real CSS, not utility classes. This is one of Svelte’s quiet strengths — scoped CSS gives you a clean place to put component styles without reaching for Tailwind. Many teams do use Tailwind anyway; both are fine. The point is that Svelte makes the without-Tailwind path pleasant.
  • State that’s shared lives in .svelte.js files with explicit exports. State that’s purely local lives in components.

13. Where to Go Deeper

A curated, opinionated reading list.

  • The Svelte tutorial (svelte.dev/tutorial). This is the official interactive tutorial. It’s exceptionally good — close to the best of any framework. Once you’ve read this document, go through the tutorial to cement the muscle memory. Two hours, well spent.

  • The Svelte docs themselves (svelte.dev/docs/svelte). Densely written, modern, accurate. The “Best Practices,” “$effect”, and “$state” pages are particularly worth reading start to finish. Bookmark the “Legacy APIs” page so you know what not to learn.

  • The Svelte 5 launch post (svelte.dev/blog/svelte-5-is-alive). Explains the why of runes in a way the reference docs don’t. Read this if you want the philosophy.

  • Rich Harris’s “Frameworks Without the Framework” (svelte.dev/blog/frameworks-without-the-framework). The 2018 post that introduced compile-time framework thinking. Old but foundational. Some details are outdated but the central thesis is the most important essay you can read about modern web frameworks.

  • Rich Harris’s “Rethinking Reactivity” talk (search YouTube). The 2019 conference talk that established Svelte’s reputation. Charismatic, clear, and unusually honest about tradeoffs. Watch when you want to understand the worldview.

  • The SvelteKit docs (svelte.dev/docs/kit). Once you’re comfortable with Svelte, SvelteKit is the natural next step. Read the “Routing,” “Loading data,” and “Form actions” sections — those are the parts that change how you build apps.

  • Joy of Code (joyofcode.xyz) and Huntabyte (YouTube channel). Two of the most reliable independent voices in the Svelte ecosystem. Joy of Code has well-paced written tutorials on Svelte 5 specifics; Huntabyte runs a YouTube channel with deep dives.

  • The Svelte Discord (svelte.dev/chat). Active, kind, the core team participates. Excellent place to ask “why is my code doing this?” The signal-to-noise is unusually high compared to most framework communities.

  • Build something real. Pick a project that uses async data, forms, real state, multiple pages. Build it in SvelteKit. The lessons that turn knowledge into intuition only come from your own bugs.


14. The Final Verdict

Svelte is the best evidence we have that the dominant web framework model — ship a runtime, diff a tree, hope for the best — was never the only option, and probably wasn’t the best one. The compile-time approach is more efficient, the syntax is lighter, the resulting code is closer to what you’d write by hand if you had infinite patience. Svelte 5 has matured this into a model that scales: runes that work everywhere, type cleanly, and remove the magic that made Svelte 4 charming but unscalable. After ten years of frameworks playing variations on React’s theme, Svelte plays a different note, and the note is good.

What it gets profoundly right. Three things. First, the compile-time-framework thesis itself — every other major framework now ships a compiler (React Server Components, Vue’s Vapor mode, Solid) precisely because Svelte demonstrated the wins were real. Second, the .svelte file format. Three blocks, scoped styles built in, template directives that read like HTML++ — this is the single most ergonomic component format in any framework, full stop. Third, the team’s relationship with backwards compatibility. The Svelte 5 migration is the most graceful major-version transition in any modern web framework. Old code keeps working. New code uses the new APIs. The automigration script handles 80% of the mechanical work. The framework cares about its users in a way that’s visible in every release note.

What it costs you. The ecosystem premium is real and won’t shrink in the timeframe you care about. You’ll spend small amounts of time recurringly looking for libraries that don’t quite exist or porting them yourself. The Proxy model gives you ergonomics-of-mutation at the cost of “what is this object, really” complexity around serialization boundaries. The “two paradigms” problem — legacy syntax alongside runes — will make the first six months of learning more confusing than it should be. And the hiring pool is what it is.

Who should reach for this. Product teams that value developer ergonomics and bundle size, can hire reasonably well, and don’t depend on a specific React-only library. Small-to-medium startups where the senior engineers can set technical direction. Anyone building a marketing site, blog, dashboard, or SaaS app with SvelteKit — that combination is uncannily productive. Game UIs, data visualization, animation-heavy interfaces — Svelte’s transition and animation primitives have no real peer.

Who shouldn’t. Large enterprises with deep React investments and rigid hiring constraints. Teams whose value depends on a specific complex React library (sophisticated rich-text editors, mature financial charting kits, advanced design systems) where the Svelte alternative is a community port a generation behind. Projects with very long maintenance horizons and a small team — React’s stability is a real advantage there. And anyone who’d choose a framework because it’s “popular” rather than because it fits — popularity is React’s game, and you won’t win that fight with Svelte.

What to believe after all this. Believe that the compiler approach won the architectural argument; even React is moving in this direction now. Believe that runes are good — the right amount of explicit, the right amount of magic, an improvement over Svelte 4 in every measurable way and a model the rest of the industry will steal from. Don’t believe the “Svelte is faster than React” benchmarks at face value — the real wins are bundle size and developer ergonomics, not steady-state performance. When you hear “Svelte is the future,” what people probably mean is “Svelte’s ideas are the future, and they’re now in React, Vue, Solid, and Svelte alike.”

The hard-won line. Svelte’s quiet superpower isn’t speed or bundle size; it’s that the framework gets out of your way. You can write a nontrivial Svelte component, look at it, and recognize it as the same code you’d have written without any framework at all — just with the boring parts compiled away. That’s a rare thing in modern frontend, and once you’ve felt it, going back to anything else feels like wearing gloves to type.


The ideas are mine. The writing is AI assisted