6 Svelte 5 Runes Fundamentals That Trip Up Even Experienced Developers

Retro developer workspace illustrating Svelte 5 runes including $state, $derived, $effect, and $inspect
Six Svelte 5 fundamentals that clarify how runes, state, expressions, data attributes, raw state, and debugging work.

Svelte 5’s runes — $state, $derived, $effect, $inspect — are a genuinely elegant reactivity model. They’re also just quirky enough that if your mental model is slightly off, you’ll end up with a perfectly “correct-looking” component that just… doesn’t do the thing. No crash, no red squiggly line, just a button that refuses to rotate and a developer quietly questioning their life choices. None of what follows is about typos or broken syntax — it’s about the underlying concepts that, once they click, make Svelte 5 feel predictable instead of moody.

1. The class Directive Wants an Expression, Not a String

Svelte 5 lets you pass an array or object straight into class, and it intelligently turns it into a class string for you:

<span class={['trigger', { open }]}>👈</span>

When open is true, this renders as class="trigger open". The key thing to understand is why this works: class={...} hands Svelte a single JavaScript expression that it evaluates and processes with its own class-name logic.

The moment you wrap that same expression in quotes, you’ve changed what you’re asking for. A quoted attribute is a string template, and Svelte just calls .toString() on whatever’s inside it — an array becomes a comma-joined string, an object becomes [object Object]. None of that matches your CSS selectors.

The underlying rule: quotes mean “build a string,” curly braces alone mean “evaluate this expression and let Svelte interpret it.” For the class shorthand specifically, you always want the latter.

2. Data Attributes Reflect State — They Don’t Create It

It’s tempting to look at this and assume the data attribute itself is doing double duty:

<button class="btn">
  <span class="trigger" data-status={status}>👈</span>
</button>

.trigger[data-status='open'] {
  rotate: -90deg;
}

The binding is correct, the selector is correct — and it still won’t animate, because a data attribute is a one-way mirror. It reflects whatever status currently holds into the DOM so CSS or other code can read it. It has no mechanism to write back to status on its own.

Something else — a click handler, a keyboard event, a form submission — has to be the thing that actually reassigns status. Once that happens, the data attribute updates automatically and your CSS kicks in. The fundamental split to internalize: events change state, attributes display state. They’re not interchangeable, and skipping one leaves the other with nothing to show.

3. $state.raw Trades Deep Reactivity for Performance

At first glance, $state.raw() looks like a drop-in replacement for $state(). It isn’t — and understanding the difference matters:

let editor = $state.raw({
  theme: 'dark',
  content: '<h1>Svelte</h1>'
})

// This has no reactive effect:
editor.content = e.target.value

Regular $state wraps your object in a Proxy, so mutating any property — even a deeply nested one — is automatically tracked and triggers updates. $state.raw skips that wrapping entirely. You get back a plain object, and Svelte only tracks reassignment of the variable itself, never changes to what’s inside it.

That means with raw state, you always replace rather than mutate:

editor = {
  ...editor,
  content: e.target.value
}

The tradeoff exists for a reason: deep-proxying large or deeply nested objects has real overhead. $state.raw is Svelte handing you a performance escape hatch — you just have to remember its one rule: swap the whole object, don’t poke at its insides.

4. Destructuring $state Keeps Each Piece Independently Reactive

Retro computer illustrating Svelte 5 reactivity concepts including $state, $inspect, class directives, and data attributes
Svelte 5 reactivity becomes easier to reason about once you understand how state, expressions, attributes, raw state, and debugging fit together.

This is one of the more pleasant surprises once you understand it. You can destructure directly off a $state() call:

let { theme, content } = $state({
  theme: 'dark',
  content: '<h1>Svelte</h1>'
})

You might expect this to just grab a snapshot of the initial values — after all, that’s what destructuring a plain object does everywhere else in JavaScript. Svelte’s compiler special-cases this pattern, though: each destructured variable becomes its own independently reactive binding, not a frozen copy. So content = 'new value' later still triggers updates across your component, even though there’s no wrapping object holding the two together anymore.

The tradeoff is grouping. Once destructured, you lose the ability to pass “the whole thing” around as a single object — to a function, a prop, or an effect that wants everything at once. If the pieces genuinely need to travel together, keep them as one $state object. If they’re used independently throughout the component, destructuring keeps things cleaner.

5. {@html} Renders Exactly the String You Give It — Nothing More

A subtle one that likes to hide behind other bugs. {@html} doesn’t know or care about the shape of your data — it renders whatever string expression you hand it, literally:

{@html editor.content}

If you pass it something that isn’t a string — the whole editor object, for instance — you won’t get an error. You’ll get [object Object] rendered silently into the page, because that’s the string representation of an object in JavaScript. The fundamental to hold onto: {@html} is a rendering instruction, not a smart lookup — it’s on you to pass the exact string you mean.

Worth saying plainly since it’s easy to lose track of: {@html} renders raw, unescaped HTML. If that content ever comes from anywhere other than a user editing their own local, trusted preview — shared documents, server-saved data, another user’s input — sanitize it first. This is a textbook XSS vector otherwise.

6. $inspect Watches Signals — It Isn’t One Itself

Easy to conflate, worth separating clearly:

let count = $state(0)
let max = $derived(count >= 4)

$inspect(max)

count and max are both signals — reactive values you can read, pass around, and build further logic on top of. $inspect is a different category of tool entirely. It doesn’t hold a value or produce one; it’s a debugging rune that subscribes to the signals you pass it and reruns a callback (console.log by default) whenever they change. Conceptually, it’s close to writing:

$effect(() => {
  console.log('max', max)
})

Two things make $inspect worth reaching for instead of a manual effect: it’s automatically stripped out of production builds, so there’s zero cost to leaving it in your code, and it supports a chainable .with() for custom logging logic, receiving a type of either 'init' or 'update'.

The Underlying Pattern

Every one of these comes back to the same idea: Svelte 5’s reactivity system is very precise about the difference between reflecting a value and controlling one, and between an expression and a string. Once those distinctions are second nature, the runes stop feeling unpredictable and start feeling like exactly what they are — a small, consistent set of rules that make your components easier to reason about, not harder.

Building something with Svelte 5 and hitting a wall? Contact Project Immerse — we’d love to help.