Svelte 5 Runes in Production: What We Learned Building 70+ Components
When Svelte 5 shipped runes, the community split in two. One side said "you're just copying React." The other saw the future of reactivity. We just started writing code. 70+ components later, powering HostingSift (a hosting comparison platform), here's what we actually know.
What runes are and why you should care
Runes are compiler instructions. They look like functions ($state(), $derived(), $effect()), but they're not. Svelte transforms them at build time, not at runtime. That's the key difference from React hooks, Vue composables, and everything else out there.
In Svelte 4, reactivity was implicit. Write let count = 0 and the compiler decides when to update the DOM. It worked, but it had traps. $: blocks got confusing with complex logic, and the line between "reactive" and "just a variable" was blurry.
Runes fix that with explicit signals:
<script lang="ts">
let hostings = $state<Hosting[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let currentSort = $state('rating')
const totalPages = $derived(Math.ceil(total / limit))
$effect(() => {
if (currentQueryString !== undefined) {
fetchHostings()
}
})
</script>
You read it and know exactly what's reactive. No magic. No guessing.
The reactivity model: what actually happens under the hood
Here's the thing most articles skip. Svelte 5's reactivity is signal-based, but the compiler does the dependency tracking for you. You never call createSignal() or wrap things in ref(). You write normal JavaScript and the compiler figures out the reactive graph.
When you write:
let loading = $state(true)
The compiler turns it into something like:
let loading = $.source(true)
Every read of loading in the template or in a $derived/$effect block registers a dependency. Every write triggers updates to anything that depends on it. No manual dependency arrays. No stale closure bugs.
This is fundamentally different from React's model. In React, useEffect re-runs based on a dependency array you maintain. Forget a dependency, get a stale value. Add too many, get infinite loops. Svelte tracks dependencies automatically because it controls the compiler. The reactive graph is built at compile time, not runtime.
Compared to Vue's ref() / reactive(), you also skip the .value ceremony. In Vue you write count.value++. In Svelte you write count++. The compiler knows it's reactive and generates the right code.
How we built a reactive filter system with runes
This is where runes really clicked for us. Our hosting comparison page has multiple filter dimensions: hosting type, technology stack, price range, search, sort order, pagination. Each filter lives in its own nanostore atom, and they all feed into a computed API query string.
Here's the store layer (plain nanostores, framework-agnostic):
// stores/filters.ts
import { atom, computed } from 'nanostores'
export const $type = atom<string | null>(null)
export const $tech = atom<string[]>([])
export const $minPrice = atom<number>(0)
export const $maxPrice = atom<number>(1000)
export const $sort = atom<string>('price-asc')
export const $page = atom<number>(1)
export const $search = atom<string>('')
// This recomputes whenever ANY filter changes
export const $apiQueryString = computed(
[$type, $tech, $minPrice, $maxPrice, $sort, $page, $search],
(type, tech, minPrice, maxPrice, sort, page, search) => {
const params = new URLSearchParams()
if (type) params.set('type', type)
if (tech.length) params.set('tech', tech.join(','))
// ...
return params.toString()
}
)
Seven independent atoms feeding into one computed query string. When you change any filter, $apiQueryString recomputes and the list refetches. That's the reactive graph in action.
Now here's the interesting part: bridging this into Svelte 5 components.
The bridge pattern we use everywhere
Nanostores don't know about runes. So we bridge them with a pattern that shows up in almost every component:
<script lang="ts">
import { $type as typeStore, setType } from '../../stores/filters'
let activeType = $state<string | null>(typeStore.get())
$effect(() => {
return typeStore.subscribe(val => { activeType = val })
})
</script>
Three lines: initial value from the store, subscribe in an effect, cleanup returned automatically. Every filter component does this. TypeTabs, TechFilter, PriceRangeSlider, ActiveFilters all follow the same pattern.
For components that track multiple stores, we batch the subscriptions:
$effect(() => {
const unsubs = [
typeStore.subscribe(val => { currentType = val }),
techStore.subscribe(val => { currentTech = val }),
minPriceStore.subscribe(val => { currentMinPrice = val }),
maxPriceStore.subscribe(val => { currentMaxPrice = val }),
priceMaxStore.subscribe(val => { ceiling = val }),
priceFloorStore.subscribe(val => { floor = val }),
]
return () => unsubs.forEach(unsub => unsub())
})
One effect, six subscriptions, one cleanup function. The effect runs once on mount, subscribes to everything, and cleans up all listeners when the component is destroyed. In React you'd need useEffect with a dependency array (probably wrong on the first try) or multiple useEffect calls. Here it's flat and obvious.
$derived for computed filter state
The real power shows when you start deriving values. Our ActiveFilters component needs to know if any filter is active:
const hasFilters = $derived(
currentType !== null ||
currentTech.length > 0 ||
currentMinPrice > floor ||
currentMaxPrice < ceiling
)
No useMemo. No cache key. Just an expression that re-evaluates when any of its dependencies change. Svelte tracks which $state variables you read inside $derived and only recalculates when those specific values change.
The TechFilter component uses $derived for show/hide logic:
const visibleOptions = $derived(
showAll ? techOptions : techOptions.slice(0, VISIBLE_COUNT)
)
const hiddenCount = $derived(
Math.max(0, techOptions.length - VISIBLE_COUNT)
)
When showAll flips or techOptions changes (after the API responds), both derived values update and the template re-renders. You don't think about it. It just works.
The price slider: reactive all the way down
Our dual-range price slider is the most reactive component we have. It juggles local drag state, store values, computed percentages, and dynamically generated pip labels:
let localMin = $state(minPriceStore.get())
let localMax = $state(maxPriceStore.get())
let ceiling = $state(priceMaxStore.get())
let isDragging = $state(false)
// Sync from store, but not while dragging
$effect(() => {
const unsubMin = minPriceStore.subscribe(val => {
if (!isDragging) localMin = val
})
const unsubMax = maxPriceStore.subscribe(val => {
if (!isDragging) localMax = val
})
// ...
return () => { unsubMin(); unsubMax(); /* ... */ }
})
// These recompute on every drag frame
const range = $derived(ceiling - floor)
const minPercent = $derived(range > 0 ? ((localMin - floor) / range) * 100 : 0)
const maxPercent = $derived(range > 0 ? ((localMax - floor) / range) * 100 : 100)
As the user drags, localMin/localMax update, which triggers minPercent/maxPercent to recompute, which moves the slider fill in the template. When they release, we commit to the store, which triggers the API fetch. The isDragging guard prevents the store subscription from fighting with the local drag state.
In React you'd model this with useState + useRef + useCallback + useEffect with carefully managed dependency arrays. Here it's just assignments and derived values. The compiler handles the rest.
The pip labels are a $derived.by() because the logic needs intermediate variables:
const pips = $derived.by(() => {
if (range <= 0) return [`$${floor}`]
const rough = range / 5
const mag = Math.pow(10, Math.floor(Math.log10(rough || 1)))
const norm = rough / mag
const step = norm <= 1 ? mag : norm <= 2 ? 2 * mag : 5 * mag
const labels: string[] = [`$${floor}`]
for (let v = Math.ceil(floor / step) * step; v < ceiling; v += step) {
labels.push(`$${v}`)
}
return labels
})
$derived() is for single expressions. $derived.by() is for when you need if, for, or temporary variables. Both are reactive the same way.
$effect: the cleanup story
The killer feature of $effect isn't the effect itself. It's the cleanup. You return a function and Svelte calls it when dependencies change or the component unmounts. No onDestroy import, no separate cleanup lifecycle.
Click outside handler with automatic cleanup:
$effect(() => {
if (searchOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
The listener only exists while the dropdown is open. Close it, listener gone. Unmount the component, listener gone. You can't forget.
Body scroll lock for a mobile bottom sheet:
let isOpen = $state(false)
function open() {
isOpen = true
document.body.style.overflow = 'hidden'
}
function close() {
isOpen = false
document.body.style.overflow = ''
}
No effect needed here because the state change is synchronous and user-driven. That's another thing runes teach you: not everything needs to be reactive. Sometimes a plain function is better.
The simplicity argument: less code, fewer bugs
Let's be blunt. Every framework claims to be simple. But simplicity isn't about marketing copy. It's about how much code you write for the same result, and how many places that code can go wrong.
Here's a toggle button. React:
const [isOpen, setIsOpen] = useState(false)
Vue:
const isOpen = ref(false)
Svelte:
let isOpen = $state(false)
All three are one-liners. Fair enough. Now let's look at something real: a component that reads from an external store, derives a computed value, and cleans up on unmount.
React version of our TypeTabs filter:
function TypeTabs() {
const [activeType, setActiveType] = useState(typeStore.get())
useEffect(() => {
const unsub = typeStore.subscribe(val => setActiveType(val))
return unsub
}, [])
const handleClick = useCallback((value) => {
setType(value)
}, [])
return (
<div className="flex flex-wrap gap-2">
{TYPES.map(t => (
<button
key={t.value}
className={`chip ${activeType === t.value ? 'active' : ''}`}
onClick={() => handleClick(t.value)}
>
{t.label}
</button>
))}
</div>
)
}
Svelte version. The actual code we ship:
<script lang="ts">
import { $type as typeStore, setType } from '../../stores/filters'
let activeType = $state<string | null>(typeStore.get())
$effect(() => {
return typeStore.subscribe(val => { activeType = val })
})
</script>
<div class="flex flex-wrap gap-2">
{#each TYPES as t}
<button
class="chip {activeType === t.value ? 'active' : ''}"
onclick={() => setType(t.value)}
>
{t.label}
</button>
{/each}
</div>
Count the concepts. React: useState, useEffect, useCallback, dependency array, JSX return statement, className instead of class, key prop. Svelte: $state, $effect, template. That's it.
The React version has more ways to break. Forget the dependency array, you get stale state. Skip useCallback, you get unnecessary re-renders (or your linter screams at you). The Svelte version has one moving part: the store subscription with automatic cleanup.
Now look at our ActiveFilters component. It subscribes to 6 stores and derives a boolean:
let currentType = $state(typeStore.get())
let currentTech = $state(techStore.get())
let currentMinPrice = $state(minPriceStore.get())
let currentMaxPrice = $state(maxPriceStore.get())
let ceiling = $state(priceMaxStore.get())
let floor = $state(priceFloorStore.get())
$effect(() => {
const unsubs = [
typeStore.subscribe(val => { currentType = val }),
techStore.subscribe(val => { currentTech = val }),
minPriceStore.subscribe(val => { currentMinPrice = val }),
maxPriceStore.subscribe(val => { currentMaxPrice = val }),
priceMaxStore.subscribe(val => { ceiling = val }),
priceFloorStore.subscribe(val => { floor = val }),
]
return () => unsubs.forEach(unsub => unsub())
})
const hasFilters = $derived(
currentType !== null ||
currentTech.length > 0 ||
currentMinPrice > floor ||
currentMaxPrice < ceiling
)
In React, this would be six useState calls, a useEffect with an empty dependency array, and a useMemo with six dependencies (that you'd probably get wrong on the first try). Or you'd pull in Zustand or Jotai and add another abstraction layer.
In Svelte, it's assignments and one effect. $derived doesn't need you to list dependencies. The compiler reads the expression and tracks them automatically.
This scales. Our most complex component (HostingList) has 12 $state declarations, 4 $derived values, 5 $effect blocks, and handles search, sort, filters, pagination, and compare selection. It's 286 lines including the template. The equivalent React component would easily be 400+, and most of that extra code would be dependency arrays, memoization, and state setter functions.
The point isn't that React is bad. It's that Svelte's compiler does work that React pushes onto the developer. Less manual bookkeeping means fewer places to mess up.
$props: TypeScript-first component contracts
interface Props {
items: CompareItem[]
max?: number
onRemove: (id: string) => void
onClear: () => void
onCompare: () => void
}
let { items, max = 4, onRemove, onClear, onCompare }: Props = $props()
Instead of export let items and hoping the parent passes the right type. TypeScript catches mistakes at compile time. Default values work with standard destructuring. Callbacks are just props, same as React.
Bundle size: the actual numbers
Svelte 5 compiles runes to vanilla JS at build time. There's no 40+ KB runtime (React) or 30+ KB runtime (Vue) shipped to the browser. Svelte's runtime is under 5 KB gzipped and tree-shakes aggressively.
In our case: 70+ Svelte components including a quiz wizard, dual-range price slider, filter system, compare bar, review form, and a full admin panel. Client JS stays under 25 KB per page (gzipped, including routing). React's runtime alone is bigger than that.
Runes add zero overhead. $state(0) compiles to:
let rating = $.source(0)
$derived(items.length >= 2) becomes:
let canCompare = $.derived(() => $.get(items).length >= 2)
The compiler knows exactly which values are reactive and generates only the code it needs. No virtual DOM diffing. No runtime dependency tracking. Just surgical DOM updates.
What surprised us
Granular updates without effort. When hostings = newArray runs, Svelte doesn't re-render the whole list. It diffs the keyed {#each} block and only touches changed DOM nodes. We never had to think about React.memo or shouldComponentUpdate.
Conditional effects actually work. $effect + if + return cleanup gives you fine-grained control in 5 lines. The click-outside handler above would be a custom hook in React (15+ lines, reusable but noisy).
TypeScript just works. $state<Hosting[]>([]) does what you expect. No wrapper types, no utility generics, no Ref<T> vs T.
Derived chains are fast. floor changes, range recomputes, minPercent recomputes, pips recomputes, DOM updates. Four levels of derived state and it's instant because each step only runs if its inputs changed.
What bites
The nanostores bridge. Four lines per store isn't terrible, but it's ceremony. We'll probably write a useNanostore() utility at some point. For now the pattern is consistent and bug-free.
$effect grabs everything it reads. If you read currentQueryString inside an effect, the effect re-runs when it changes. This is correct behavior, but you need to be careful not to trigger infinite loops (effect writes to state that the effect reads).
Migrating from Svelte 4. Not trivial. $: blocks need to be split into $derived and $effect. export let becomes $props(). The good news: npx sv migrate svelte-5 handles 90% of cases.
Quick reference
| Rune | Use case | Example |
|---|---|---|
$state() |
Mutable value | let loading = $state(true) |
$derived() |
Computed from state | const total = $derived(items.length) |
$derived.by() |
Complex computed with if/for | $derived.by(() => { ... }) |
$effect() |
Side effects, subscriptions | Fetch, event listeners, store sync |
$props() |
Component inputs | let { name } = $props() |
$bindable() |
Two-way binding prop | let { value = $bindable() } = $props() |
Bottom line
Runes make reactivity explicit. You know what's state, what's computed, what's a side effect. The compiler generates minimal code. TypeScript works without hacks. The reactive graph is built once at compile time and runs with near-zero overhead.
We run 70+ components in production at HostingSift with Astro SSR, nanostores for cross-island state, and Svelte 5 runes for local reactivity. A full filter system with type tabs, technology chips, a dual-range price slider, active filter pills, search with autocomplete, and sort dropdown. All reactive, all coordinated through a single computed query string, all under 25 KB shipped to the browser.
If you're starting a new project and the React/Vue/Svelte decision is eating your time, just try runes. They're different enough to deserve a real look.
Top comments (0)