Split View: Svelte 5 Runes — 반응성 재설계, 그리고 Solid·Vue·MobX·React Compiler와의 정면 비교 (2026 심층)
Svelte 5 Runes — 반응성 재설계, 그리고 Solid·Vue·MobX·React Compiler와의 정면 비교 (2026 심층)
프롤로그 — 컴파일러 마법이 끝나고, 룬이 시작되었다
Svelte 3은 약속이었다. "리액티브 코드는 컴파일이 처리해야 한다, 런타임은 가벼워야 한다." let count = 0이 반응성을 갖고, $: doubled = count * 2가 자동으로 추적되고, 우리는 React의 useState 의존성 배열 없이도 살 수 있었다.
그러나 그 약속에는 작은 글씨가 있었다.
- 컴포넌트 안에서만 반응성 —
.svelte파일 밖, 일반.ts/.js에서는$:이 동작하지 않는다. $:라벨의 두 얼굴 — 같은 문법이 "파생 값"이기도 하고 "사이드이펙트"이기도 했다. 컴파일러가 알아서 판별했지만, 사람은 종종 헷갈렸다.let이 마법이었다 — 컴포넌트 최상위let은 반응성이고, 함수 안의let은 평범한 변수다. 둘이 같은 문법인 게 문제였다.- TypeScript와의 마찰 — 컴파일러가 의미를 바꾸는 식별자에 타입을 붙이기는 까다로웠다.
2024년 10월 22일, Svelte 5가 GA로 나왔다. 그리고 한 줄로 그 모든 작은 글씨를 지웠다.
"반응성은 더 이상 마법이 아니다. 룬이다."
룬(rune)은 $state·$derived·$effect·$props로 시작하는 네 개의 함수처럼 보이는 컴파일러 키워드다. 컴포넌트 안에서도, 밖에서도, 클래스 안에서도, 모듈 어디에서도 똑같이 동작한다. 의미는 한 줄로 읽힌다.
<script>
// Svelte 5
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
이 글은 그 변화를 처음부터 끝까지 — 왜·무엇을·어떻게·언제 마이그레이션할지 — 한 호흡으로 정리한다. 그리고 그 옆에 Solid의 시그널, Vue의 ref/computed, MobX의 observable, React Compiler의 자동 메모이제이션을 놓고, "2026년 우리는 어디쯤 와 있는가"를 묻는다.
1장 · 왜 룬인가 — 암묵에서 명시로
먼저 Svelte 4와 Svelte 5의 같은 카운터를 나란히 본다.
<!-- Svelte 4 -->
<script>
let count = 0
$: doubled = count * 2
$: if (count > 10) console.log('high')
function increment() {
count += 1
}
</script>
<button on:click={increment}>{count}</button>
<p>doubled: {doubled}</p>
<!-- Svelte 5 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => {
if (count > 10) console.log('high')
})
function increment() {
count += 1
}
</script>
<button onclick={increment}>{count}</button>
<p>doubled: {doubled}</p>
차이는 두 가지다.
- 반응성이 식별자에 붙어 있다, 위치에 붙어 있지 않다. Svelte 4는 "최상위
let"이라는 위치 규칙이었다. Svelte 5는$state()를 통과한 값만 반응적이다. $:라벨의 두 얼굴이 분리되었다. 파생 값은$derived, 사이드이펙트는$effect로 명시한다.
이게 왜 중요한가? 세 가지 시나리오를 보자.
1.1 컴포넌트 밖에서의 반응성
Svelte 4에서 카운터 로직을 별도 파일로 빼고 싶었다고 하자.
// counter.js (Svelte 4 시대 — 동작하지 않는다)
let count = 0
$: doubled = count * 2 // ← .svelte 파일이 아니면 컴파일러가 무시
$:는 .svelte 파일의 컴파일러 지시어였다. 일반 .js/.ts에서는 무의미한 JS 라벨 문법이었다. 그래서 우리는 store라는 별도의 추상을 썼다. writable·readable·derived 그리고 $store 접두 문법.
Svelte 5에서는?
// counter.svelte.js
export function createCounter() {
let count = $state(0)
let doubled = $derived(count * 2)
return {
get count() { return count },
get doubled() { return doubled },
increment: () => (count += 1),
}
}
확장자가 .svelte.js 또는 .svelte.ts면 컴파일러가 룬을 처리한다. 결과적으로 store가 따로 필요 없다. 룬 하나로 컴포넌트 안과 밖이 통일된다.
1.2 $:의 두 얼굴
Svelte 4의 $:는 같은 문법이 두 가지 의미를 가졌다. 컴파일러가 우변을 분석해서 "할당이면 파생, 표현식이면 이펙트"로 갈랐다.
<script>
let a = 1
let b = 2
$: sum = a + b // ← 파생 (할당)
$: console.log(a, b) // ← 이펙트 (표현식)
$: if (sum > 5) alert() // ← 이펙트 (if 문)
</script>
읽는 사람은 매번 우변을 한 번 더 봐야 했다. Svelte 5는 둘을 분리한다.
<script>
let a = $state(1)
let b = $state(2)
let sum = $derived(a + b)
$effect(() => console.log(a, b))
$effect(() => { if (sum > 5) alert() })
</script>
1.3 의존성 추적
둘 다 정적 분석으로 의존성을 추적한다. React는 의존성 배열을 사람이 적는다 — useMemo(() => a + b, [a, b]). Svelte는 적지 않는다. 차이는 React Compiler가 자동화하려는 것과 같은 지점이다.
2장 · 네 개의 룬 — $state·$derived·$effect·$props
2.1 $state — 반응성의 출발점
<script>
let count = $state(0)
let user = $state({ name: '영주', age: 32 })
let tags = $state(['svelte', 'runes'])
function rename() {
user.name = '영주2' // ← 깊이 반응성 (deep reactivity), 동작한다
tags.push('signals') // ← 배열 변이도 추적된다
}
</script>
$state는 기본적으로 깊은 반응성이다. 객체·배열을 Proxy로 감싸 깊은 속성 변경까지 추적한다. 큰 객체에서 오버헤드가 부담스러우면 $state.raw()로 얕은 반응성만 가져갈 수 있다.
let snapshot = $state.raw({ huge: data })
// snapshot.huge.foo = 'bar' ← 추적 안 됨
snapshot = { ...snapshot, huge: { ...snapshot.huge, foo: 'bar' } } // ← 추적됨
2.2 $derived — 파생, 그리고 게으름
<script>
let count = $state(0)
let doubled = $derived(count * 2)
let expensive = $derived.by(() => {
// 복수 줄 계산이 필요할 때
let acc = 0
for (let i = 0; i < count; i++) acc += i
return acc
})
</script>
$derived는 게으르고 캐시된다. 읽지 않으면 계산되지 않고, 의존성이 바뀌지 않으면 재계산되지 않는다. 이건 Solid의 createMemo, Vue의 computed와 같은 의미다.
2.3 $effect — 사이드이펙트의 명시적 게이트
<script>
let url = $state('/api/me')
$effect(() => {
const ctrl = new AbortController()
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(console.log)
return () => ctrl.abort() // ← cleanup 함수
})
</script>
$effect는 DOM 마운트 후, 그리고 의존성이 바뀔 때마다 실행된다. cleanup도 React useEffect와 같은 모양으로 반환한다. $effect.pre는 DOM 업데이트 이전에 실행되고, $effect.root는 명시적으로 dispose할 수 있는 효과 트리를 만든다.
핵심 규칙: $effect 안에서 $state를 쓰지 마라. 무한 루프와 동기화 폭발의 주범이다. 그게 $derived가 따로 있는 이유다.
2.4 $props — 컴포넌트 입력의 새 문법
<!-- Svelte 4 -->
<script>
export let name
export let age = 0
export let onSave
</script>
<!-- Svelte 5 -->
<script>
let { name, age = 0, onSave, ...rest } = $props()
</script>
여러 export let을 한 줄의 구조분해로 묶었다. 기본값·rest props·이름 변경까지 표준 JS 문법으로 풀린다. TypeScript와도 자연스럽다.
<script lang="ts">
interface Props {
name: string
age?: number
onSave: (id: string) => void
}
let { name, age = 0, onSave }: Props = $props()
</script>
추가로 $bindable()로 양방향 바인딩을 명시하고, $host()로 커스텀 엘리먼트 호스트에 접근한다.
3장 · 룬 vs 시그널 vs ref vs observable vs useState — 비교 매트릭스
여기서 한 페이지로 정리하자. 다섯 가지 반응성 모델을 같은 카운터로 본다.
<!-- Svelte 5 / Runes -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
// Solid / Signals
function Counter() {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
}
<!-- Vue 3 / Composition + ref -->
<script setup>
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
watchEffect(() => console.log(count.value))
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
// MobX
import { makeAutoObservable } from 'mobx'
import { observer } from 'mobx-react-lite'
class Store {
count = 0
constructor() { makeAutoObservable(this) }
get doubled() { return this.count * 2 }
inc() { this.count += 1 }
}
const store = new Store()
const Counter = observer(() => (
<button onClick={() => store.inc()}>{store.count}</button>
))
// React 19 + React Compiler
function Counter() {
const [count, setCount] = useState(0)
// Compiler가 알아서 메모이즈한다 — 수동 useMemo 없음
const doubled = count * 2
useEffect(() => console.log(count), [count])
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
비교 매트릭스 — 같은 차원을 다섯 줄로.
| 항목 | Svelte 5 Runes | Solid Signals | Vue 3 ref/computed | MobX | React + Compiler |
|---|---|---|---|---|---|
| 읽기 문법 | count (식별자) | count() (호출) | count.value (속성) | store.count (속성) | count (구조분해) |
| 쓰기 문법 | count++ | setCount(c => c + 1) | count.value++ | store.count++ | setCount(c => c + 1) |
| 파생 | $derived(...) | createMemo(...) | computed(...) | get ...() (게터) | 자동 (Compiler) |
| 효과 | $effect(...) | createEffect(...) | watchEffect(...) | autorun(...) | useEffect(..., [deps]) |
| 컴포넌트 밖 | .svelte.ts | 어디서나 | 어디서나 | 어디서나 | 훅은 안 됨 |
| 깊은 반응성 | 기본 (Proxy) | 얕음 (스토어로 보강) | 기본 (Proxy) | 기본 (Proxy/getter) | 얕음 (참조 비교) |
| 컴파일 단계 | 컴파일타임 변환 | 컴파일타임 변환 | 런타임 Proxy | 런타임 Proxy | 컴파일러 + 런타임 훅 |
| 의존성 배열 | 없음 (자동) | 없음 (자동) | 없음 (자동) | 없음 (자동) | 사람 또는 Compiler |
| TypeScript | 자연 | 자연 | 자연 | 데코레이터 가능 | 자연 |
| 번들 무게 | 매우 작음 | 매우 작음 | 중간 | 중간 | 큼 |
한 줄로 — **Svelte 5는 "컴파일 시간에 변환되는 시그널"**이다. Solid와 가장 가깝다. 다만 호출 문법(count())이 없어 보통의 JS 변수처럼 읽힌다.
4장 · $:에서 룬으로 — 실전 마이그레이션
좋은 소식: legacy 모드가 계속 동작한다. Svelte 5는 같은 컴파일러가 $:도 처리한다. svelte.config.js에서 compilerOptions.runes = false를 명시하거나, 컴포넌트 최상단에 <svelte:options runes={false}/>를 쓰면 4 시대 문법이 그대로 된다.
또 한 가지 좋은 소식: 컴포넌트 단위로 점진 마이그레이션이 가능하다. 한 컴포넌트는 룬, 다른 컴포넌트는 $:, 한 프로젝트에 공존한다.
자주 만나는 변환 패턴 다섯 개를 살펴본다.
4.1 let → $state
<!-- before -->
<script>
let count = 0
</script>
<!-- after -->
<script>
let count = $state(0)
</script>
마이그레이션 스크립트(npx sv migrate svelte-5)가 이 변환을 거의 자동으로 해 준다. 단, "쓰여지지 않는 let"은 변환하지 않는다 — 반응성이 필요 없는 변수다.
4.2 $: foo = ... → $derived
<!-- before -->
<script>
let a = 1, b = 2
$: sum = a + b
</script>
<!-- after -->
<script>
let a = $state(1), b = $state(2)
let sum = $derived(a + b)
</script>
4.3 $: { ... } 또는 $: if (...) → $effect
<!-- before -->
<script>
let count = 0
$: if (count > 10) document.title = `high: ${count}`
</script>
<!-- after -->
<script>
let count = $state(0)
$effect(() => {
if (count > 10) document.title = `high: ${count}`
})
</script>
4.4 stores → 룬 in 클래스
Svelte 4의 store 패턴을 클래스 + 룬으로 옮기는 것이 2026년 권장 패턴이다.
// before — store
import { writable, derived } from 'svelte/store'
function createCart() {
const items = writable([])
const count = derived(items, ($i) => $i.length)
return {
items,
count,
add: (item) => items.update((arr) => [...arr, item]),
}
}
// after — runes in class (.svelte.ts)
export class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
export const cart = new Cart()
호출부도 더 짧다. $cart.count 같은 store 접두사가 없어진다. cart.count다.
4.5 on:click → onclick
이건 룬과 직접 관계는 없지만 Svelte 5의 변화다.
<!-- before -->
<button on:click={handler}>x</button>
<input on:input={handler} bind:value={text} />
<!-- after -->
<button onclick={handler}>x</button>
<input oninput={handler} bind:value={text} />
on:click 같은 콜론 디렉티브 대신 표준 HTML 속성처럼 적는다. 이는 props로 핸들러를 전달하는 패턴과 자연스럽게 맞물린다.
5장 · SvelteKit 2 — 데이터 흐름이 명시적이 되었다
룬과 같은 방향의 변화가 SvelteKit에도 일어났다. 2024년 초의 SvelteKit 2.0이 도입한 두 변화가 2026년 표준이 되었다.
5.1 await 명시 — load가 더 이상 마법이 아니다
// before (SvelteKit 1)
export const load = async ({ fetch, params }) => {
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
// after (SvelteKit 2)
export const load = async ({ fetch, params, depends }) => {
depends('app:post')
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
depends로 무효화 키를 명시하고, invalidate('app:post')로 다시 부른다. 이전엔 URL 패턴 매칭으로 암묵적으로 됐다 — 이젠 명시적이다.
5.2 폼 액션과 enhance — JS 없이도, JS와 함께도
SvelteKit의 폼 액션은 룬과 무관하게 잘 살아 있다. 점진적 향상의 정수를 보여준다.
<script>
import { enhance } from '$app/forms'
let { form } = $props()
</script>
<form method="POST" use:enhance>
<input name="title" />
{#if form?.error}
<p>오류: {form.error}</p>
{/if}
<button>저장</button>
</form>
// +page.server.js
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const title = data.get('title')
if (!title) return { error: 'title required' }
await db.posts.create({ title })
return { success: true }
},
}
JS가 꺼져도 동작한다. JS가 켜져 있으면 enhance가 fetch로 가로채 SPA처럼 처리한다.
5.3 Universal vs Server load
+page.js(universal)와 +page.server.js(server-only)의 구분이 룬으로 더 명확해졌다. 클라이언트에서 다시 도는 universal load에서 $state를 만들면 hydration 후에도 반응성이 살아 있다. 서버 전용 load는 직렬화 가능한 JSON만 반환한다.
6장 · 실 사례 — 누가 룬을 쓰고 있는가
룬은 GA 1년 반 만에 Svelte 생태계 거의 전체에 퍼졌다.
- SvelteKit 자체가 룬 기반의 데모와 템플릿을 기본으로 제공한다.
- Skeleton UI v3 (Tailwind 4 기반 컴포넌트 라이브러리)가 룬 기반으로 다시 쓰였다.
- shadcn-svelte가 룬으로 마이그레이션됐다. 더 이상 store 접두사가 없다.
- TanStack Query for Svelte v5가 룬 기반 API를 채택했다.
- HeyApi·Bits UI·Melt UI — 모두 룬으로 이주했거나 이주 중이다.
그리고 큰 사용자들 — Spotify·Apple·1Password 일부 팀이 Svelte를 쓰고 있고, Apple Music의 일부 웹 표면이 Svelte로 알려져 있다. 다만 2026년 시점의 공식 통계로는 State of JS 2024 기준으로 Svelte의 retention(쓴 사람이 다시 쓸 의향)이 React·Vue와 비교해 가장 높은 축에 든다.
7장 · 무엇을 포기했나 — 정직한 거래
룬은 거의 모든 면에서 4를 이긴다. 그러나 "전부 좋은가"는 아니다.
7.1 어휘 수가 늘어났다
let count = 0에서 let count = $state(0)으로. 키 입력이 늘어났다. Svelte의 매력 중 하나였던 "JS 같은 간결함"이 살짝 흐려졌다. 다만 그 대가로 명시성·일관성·확장성을 얻었다.
7.2 Proxy 비용
$state는 객체·배열을 Proxy로 감싼다. 대규모 데이터 구조에서는 오버헤드가 있다. 권장: 핫 패스에는 $state.raw()를 쓰거나, 불변 자료구조를 한 덩어리로 갈아끼우는 패턴.
7.3 컴포넌트 밖 룬 = .svelte.ts 확장자
룬을 쓰는 일반 모듈은 .svelte.js 또는 .svelte.ts여야 한다. 그렇지 않으면 컴파일러가 처리하지 않는다. 작은 거지만 빌드 설정이 익숙하지 않으면 함정이다.
7.4 $effect의 함정
$effect 안에서 $state를 쓰면 무한 루프가 도는 게 가능하다. ESLint 룰(svelte/no-reactive-reassign)과 컴파일러 경고가 잡아 주지만, 사람의 주의가 여전히 필요하다.
7.5 학습 곡선
Svelte 4를 알던 사람에겐 두 버전을 동시에 머리에 둬야 하는 기간이 있다. 룬이 다 좋아도, 기존 자료의 절반은 $: 문법이다.
7.6 React 생태계만큼은 아니다
이건 룬 자체의 문제가 아니라 생태계의 크기 문제다. 2026년에도 npm 다운로드, 라이브러리 수, 채용 시장 모두 React가 더 크다. 다만 개발자 만족도·러닝 곡선·번들 크기·런타임 성능에서는 Svelte가 우위다.
8장 · "React-but-better"는 2026년에도 참인가
오래된 명제가 있다. "Svelte = React-but-better." 이걸 2026년에 다시 묻자.
8.1 React 19와 React Compiler
React 19가 use()·Actions·서버 컴포넌트의 시대를 굳혔다. React Compiler가 GA에 들어가면서 수동 useMemo·useCallback이 줄었다. 메모이제이션의 자동화는 사실 Solid·Svelte·Vue가 이미 갖고 있던 것 — React가 마침내 따라왔다.
그래도 React가 이기는 곳:
- 생태계 크기 — 라이브러리·툴·채용·자료 다 더 많다.
- 서버 컴포넌트(RSC) — Svelte도 비슷한 방향(서버 데이터 + 클라이언트 인터랙션)이지만, React가 추상의 깊이에서 앞선다.
- 호환성 기반 — React Native·Expo·매우 큰 회사들의 표준.
8.2 Svelte 5가 이기는 곳
- 번들 크기 — 비교할 수 없을 만큼 작다. 라우터·반응성·DOM 처리 모두 합쳐서 React + ReactDOM의 1/5 수준.
- 명시적 반응성 — 룬이 의도를 코드에 박는다. React Compiler가 "자동"이라면, 룬은 "선언"이다.
- 컴포넌트 안과 밖의 일관성 — 룬은 어디서나 같다. React 훅은 컴포넌트(또는 다른 훅) 안에서만 된다.
- DX의 단순함 —
bind:value·transition:fade·use:enhance같은 디렉티브가 그대로 쓰인다.
8.3 결론은 결국 "맥락"
"Svelte = React-but-better"는 개발 경험에서는 여전히 참이다. 생태계의 폭과 깊이에서는 React가 여전히 크다. 채용·기존 코드·팀 익숙함을 빼면 Svelte 5의 매력은 GA 시점보다 더 커졌다. 룬이 그 마지막 약점("컴포넌트 밖에서의 반응성")을 메웠기 때문이다.
9장 · Solid·Vue와 더 깊게
룬을 시그널과 ref와 같은 자리에 놓는다면, 디테일에서 어디가 다른가.
9.1 vs Solid Signals
// Solid
const [count, setCount] = createSignal(0)
console.log(count()) // ← 함수 호출
setCount(1)
setCount(c => c + 1)
<!-- Svelte 5 -->
<script>
let count = $state(0)
console.log(count) // ← 변수처럼
count = 1
count += 1
</script>
Solid는 시그널을 함수로 만든다. 호출이 추적 단위가 된다. 장점: 어디서 추적되는지 코드에서 바로 보인다. 단점: 키 입력이 늘고, 객체 안에서 시그널을 다루기가 살짝 번거롭다(createStore로 풀긴 한다).
Svelte 5는 컴파일러가 같은 일을 한다. count 식별자 접근을 자동으로 추적 호출로 변환한다. 장점: 보통 JS처럼 읽힌다. 단점: "지금 추적되고 있는가"를 보려면 컴파일 결과를 봐야 한다.
9.2 vs Vue 3 ref/computed
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
Vue는 .value를 통해 ref 접근을 명시한다. 템플릿 안에서는 .value가 자동 unwrap된다. 룬과 가장 가까운 모델이지만, 컴파일 시점이 아니라 런타임 Proxy라는 차이가 있다.
Vue 3.4+는 Vapor 모드(컴파일타임 반응성, Solid·Svelte 스타일)를 실험 중이다. 2026년 시점에 Vapor가 일반화되면, ref와 룬의 거리는 더 좁혀진다.
9.3 vs MobX
MobX는 OOP 친화적이다. 클래스에 makeAutoObservable을 부르면 모든 필드가 자동 observable, 모든 getter가 자동 computed가 된다.
Svelte 5의 "runes in classes" 패턴은 의도적으로 MobX와 닮았다.
// Svelte 5
class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
// MobX
class Cart {
items = []
constructor() { makeAutoObservable(this) }
get count() { return this.items.length }
add(item) { this.items.push(item) }
}
MobX는 데코레이터 또는 호출로 변환한다. Svelte 5는 룬으로 명시한다. 의미는 거의 같다.
10장 · 컴파일 결과를 들여다보기 — 무엇이 진짜로 일어나나
룬을 쓴 코드는 컴파일러가 뭐로 바꿀까. 단순한 예를 추적해 보자.
<!-- 소스 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
대략적으로 컴파일 결과는 이렇게 된다(개념적, 실제 출력은 더 복잡하다).
import * as $ from 'svelte/internal/client'
function Component($$anchor) {
let count = $.state(0)
let doubled = $.derived(() => $.get(count) * 2)
$.user_effect(() => console.log($.get(count)))
const button = $.template('<button> </button>')()
$.event('click', button, () => $.set(count, $.get(count) + 1))
$.text(button.firstChild, () => $.get(count))
$.append($$anchor, button)
}
핵심:
- 식별자
count가 시그널 셀로 변환된다. count읽기는$.get(count)호출로, 쓰기는$.set(count, ...)호출로 변환된다.$derived는$.derived(thunk)로 — Solid의createMemo와 사실상 같은 구현이다.$effect는$.user_effect(thunk)로 — 마운트 이후, 의존성 변경 시마다 재실행된다.
즉 룬은 사람이 시그널을 호출 없이 쓰게 만든 컴파일러 마법이다. 4의 마법과 다른 점은, 마법의 트리거가 위치가 아니라 명시적 토큰이라는 것.
11장 · 큰 앱에서의 룬 — 패턴과 안티패턴
11.1 권장 패턴
- 도메인 로직은
.svelte.ts모듈에 두기 — 컴포넌트는 표현에 집중. - 클래스 + 룬으로 상태 컨테이너 — store보다 타입이 자연스럽다.
$derived로 파생,$effect로만 부수 효과 — 둘을 섞지 않는다.$effect의 cleanup 함수를 항상 챙긴다. 메모리 누수의 80%가 여기서 난다.- 얕은 반응성이 필요하면
$state.raw— 큰 트리에서 Proxy 비용을 피한다. - prop 타입은 인터페이스로 —
let { ... }: Props = $props(). $bindable()은 정말 양방향이 필요한 곳만 — 단방향 데이터 흐름이 디폴트.
11.2 안티패턴
$effect안에서$state쓰기 — 무한 루프. 파생이 필요하면$derived.- module-level
$state를 직접 export — 여러 SSR 요청이 공유한다. 함수로 감싸야 한다. $:잔재를 룬과 섞기 — legacy 모드 아니면 컴파일러가 거부한다. 한 컴포넌트는 한쪽으로 통일.- 거대한 객체를
$state— Proxy가 깊이 들어가 비싸다. 평탄화하거나$state.raw. - store를 룬과 같이 운영 — 가능하지만 두 모델을 머리에 같이 둬야 한다. 가능하면 일관화.
$effect로 데이터 페치 — 가능하지만 SvelteKitload가 더 좋다.$effect는 정말 클라이언트 사이드 부수 효과용.$inspect를 프로덕션에 두기 — 개발용 디버깅 룬이다.
12장 · 도구·생태계 — 룬 시대의 주변부
12.1 IDE·언어 서버
VS Code의 Svelte for VS Code 확장이 룬을 완벽히 인식한다. JetBrains WebStorm 2024.3+도 같다. 룬이 컴파일러 키워드라서, 의미는 LSP가 알려준다.
12.2 ESLint·Prettier
eslint-plugin-svelte가 룬용 규칙 묶음을 제공한다. svelte/no-reactive-reassign, svelte/require-state-with-init 같은 게 핵심이다.
12.3 테스트
vitest + @testing-library/svelte가 표준. .svelte.ts 안의 룬을 테스트할 때는 flushSync()로 동기 상태 업데이트를 강제하는 패턴이 자주 쓰인다.
12.4 빌드
Vite 5가 권장 번들러, Vite 6도 호환. SvelteKit이 그 위에 라우팅·SSR·어댑터를 얹는다. Bun도 Svelte 5를 정식 지원하지만, SvelteKit 어댑터는 Node·Vercel·Cloudflare·Netlify가 더 성숙하다.
12.5 컴포넌트 라이브러리
- shadcn-svelte — 룬 기반 무스타일 컴포넌트, 복사-붙여넣기 모델.
- Skeleton v3 — 룬 + Tailwind 4.
- Bits UI — 헤드리스 컴포넌트, 룬 기반.
- Melt UI — 빌더 패턴 헤드리스, 룬 마이그레이션 완료.
13장 · 미래 — 룬 다음은 무엇인가
Svelte 코어팀이 공개적으로 이야기하는 다음 방향은 세 가지다.
- Svelte 6 — 룬 기반 API를 안정화하고, legacy 모드를 점진적으로 제거. 6에서 legacy는 deprecation, 7에서 제거 후보.
- 컴파일러 최적화 — Vapor mode 같은 발상이 Svelte 5 위에 더 깊이 들어간다. fine-grained 업데이트는 이미 하고 있고, 그 위에 텍스트·속성·부분 트리 단위 최적화가 더해진다.
- SvelteKit과 RSC의 경계 — React 서버 컴포넌트와 같은 종류의 분리(서버 부품 vs 클라이언트 부품)를 룬·
load·폼 액션 위에서 어떻게 더 자연스럽게 풀지가 과제.
그리고 더 큰 그림: 시그널의 표준화. TC39 시그널 제안이 진행 중이다. Svelte·Solid·Vue·Angular가 비슷한 기본형을 공유하면, 룬은 시그널 호출 위의 표면 문법이 된다.
에필로그 — 명시성을 향한 한 걸음
Svelte 4의 let은 아름다웠다. "그냥 JS"였다. 그러나 "그냥 JS"가 아니었다. 컴파일러가 의미를 바꿨다. 그 비밀이 새로운 사람의 발목을 잡았고, TypeScript와 마찰했고, 컴포넌트 밖으로 못 나갔다.
Svelte 5의 룬은 그 비밀을 코드에 박았다. $state가 있는 곳에 반응성이 있다. 더 길어졌지만, 더 정직해졌다. Solid·Vue·MobX가 다 자기 식으로 도달한 같은 결론을 — Svelte는 자기 식으로 도달했다.
그리고 그 결론은 한 문장으로 요약된다.
"마법은 좋다. 다만 마법을 부르는 단어는 사람에게 보여야 한다."
12개 항목 체크리스트
.svelte컴포넌트가 룬 모드에 있는가(runes: true또는 자동)?let변수가 진짜 반응성이 필요한가 — 필요한 경우만$state?- 파생 값은
$derived로, 효과는$effect로 명확히 분리됐는가? $effect안에서$state에 쓰지 않는가?$effect의 cleanup 함수를 챙겼는가?- 큰 객체는
$state.raw로 얕은 반응성만 가져갔는가? - 컴포넌트 밖 룬 모듈이
.svelte.ts확장자인가? - props는
let { ... }: Props = $props()스타일로 적었는가? - 양방향 바인딩이 필요한 prop만
$bindable()인가? - store에서 룬으로 마이그레이션할 때 SSR 안전한가(함수로 감싸기)?
- SvelteKit
load의 무효화를depends/invalidate로 명시했는가? - legacy 모드의 잔재(
$:·on:click·export let)가 새 코드에 섞이지 않았는가?
안티패턴 10가지
$effect안에서$state쓰기 — 무한 루프.- module-level
$state를 그대로 export — SSR 공유 버그. - 거대한 객체를
$state— Proxy 비용. $:와 룬을 같은 컴포넌트에 섞기.- store와 룬을 같은 도메인에 공존시키기.
$effect로 데이터 페치 —load가 더 맞다.$inspect·$state.snapshot디버깅 코드를 프로덕션에 남기기.$bindable()을 단방향 데이터에 남용..svelte파일이 아닌 곳에 룬 쓰기.- 룬을 도입하면서 컴파일러 경고를 끄기.
다음 글 예고
다음 글 후보: SvelteKit 2 데이터 흐름 심층 — load·actions·enhance의 진짜 의미, 시그널 표준화(TC39) 추적 — Svelte·Solid·Vue·Angular가 공유할 미래, Svelte vs Astro vs SolidStart — 콘텐츠 사이트의 2026년 선택지.
"반응성은 마법이 아니라 도구다. 도구의 이름은 코드에 적혀 있어야 한다."
— Svelte 5 Runes, 끝.
참고 / References
- Svelte 공식 사이트
- Svelte 5 발표 블로그
- Svelte 5 마이그레이션 가이드
- Runes 문서
$state레퍼런스$derived레퍼런스$effect레퍼런스$props레퍼런스- SvelteKit 공식 문서
- SvelteKit 2 마이그레이션
- Solid 공식 — Reactivity
- Vue 3 Reactivity API
- Vue Vapor mode RFC
- MobX 공식
- React Compiler 문서
- TC39 Signals 제안
- State of JS 2024
- shadcn-svelte
- Skeleton UI
- Bits UI
- Melt UI
Svelte 5 Runes — The Reactivity Rewrite, and a Head-to-Head with Solid, Vue, MobX, and the React Compiler (2026 Deep Dive) (english)
Prologue — The compiler magic ended; the runes began
Svelte 3 was a promise. "Reactivity should be a compiler concern; the runtime should be tiny." let count = 0 could be reactive, $: doubled = count * 2 would track its dependencies, and we lived without React's useState-and-dependency-array dance.
But the promise had fine print.
- Reactivity only inside components. Outside a
.sveltefile — in plain.tsor.js—$:did nothing. $:had two faces. The same syntax meant "derived value" sometimes and "side effect" other times. The compiler disambiguated; humans often did not.letwas magic. A top-levelletin a component was reactive; aletinside a function was a normal variable. Same syntax, different meaning.- TypeScript friction. Typing identifiers whose semantics the compiler rewrote was awkward.
On October 22, 2024, Svelte 5 shipped GA. And erased all that fine print with a single line.
"Reactivity is no longer magic. It is runes."
A rune is a $-prefixed compiler keyword that looks like a function call — $state, $derived, $effect, $props, plus a handful more. Runes work inside components, outside components, inside classes, anywhere. The meaning is on the line where you wrote it.
<script>
// Svelte 5
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
This piece walks through it end to end — why, what, how, and when to migrate — then puts runes next to Solid signals, Vue ref/computed, MobX observables, and the React Compiler's auto-memoization, and asks where we actually stand in 2026.
1. Why runes — from implicit to explicit
Same counter, Svelte 4 versus Svelte 5, side by side.
<!-- Svelte 4 -->
<script>
let count = 0
$: doubled = count * 2
$: if (count > 10) console.log('high')
function increment() {
count += 1
}
</script>
<button on:click={increment}>{count}</button>
<p>doubled: {doubled}</p>
<!-- Svelte 5 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => {
if (count > 10) console.log('high')
})
function increment() {
count += 1
}
</script>
<button onclick={increment}>{count}</button>
<p>doubled: {doubled}</p>
Two real differences:
- Reactivity is attached to identifiers, not positions. Svelte 4 had a positional rule ("top-level
let"). Svelte 5 only treats values that pass through$state()as reactive. - The two faces of
$:are now separated. Derived values are$derived. Side effects are$effect.
Why does this matter? Three scenarios.
1.1 Reactivity outside components
Say you wanted to extract counter logic into its own file in the Svelte 4 era.
// counter.js (Svelte 4 era — does not work)
let count = 0
$: doubled = count * 2 // ← compiler ignores this outside a .svelte file
$: was a compiler directive that only existed inside .svelte files. In plain .js or .ts it was just a JS label, meaningless at runtime. So we used stores instead — writable, readable, derived, and the $store auto-subscription prefix in templates.
In Svelte 5:
// counter.svelte.js
export function createCounter() {
let count = $state(0)
let doubled = $derived(count * 2)
return {
get count() { return count },
get doubled() { return doubled },
increment: () => (count += 1),
}
}
If the file extension is .svelte.js or .svelte.ts, the compiler processes runes. The practical consequence: stores are largely unnecessary now. Runes unify inside and outside components.
1.2 The two faces of $:
Svelte 4's $: did double duty. The compiler inspected the right-hand side: assignment meant derived, expression meant effect.
<script>
let a = 1
let b = 2
$: sum = a + b // ← derived (assignment)
$: console.log(a, b) // ← effect (expression)
$: if (sum > 5) alert() // ← effect (if statement)
</script>
Readers had to scan the right-hand side every time. Svelte 5 separates them.
<script>
let a = $state(1)
let b = $state(2)
let sum = $derived(a + b)
$effect(() => console.log(a, b))
$effect(() => { if (sum > 5) alert() })
</script>
1.3 Dependency tracking
Both versions track dependencies via static analysis. React, by contrast, makes humans write the dependency array — useMemo(() => a + b, [a, b]). Svelte does not. This is exactly the gap the React Compiler is trying to close.
2. The four runes — $state, $derived, $effect, $props
2.1 $state — where reactivity begins
<script>
let count = $state(0)
let user = $state({ name: 'YJ', age: 32 })
let tags = $state(['svelte', 'runes'])
function rename() {
user.name = 'YJ2' // ← deep reactivity, works
tags.push('signals') // ← array mutation is tracked
}
</script>
$state is deeply reactive by default. Objects and arrays are wrapped in a Proxy that tracks nested mutations. When the overhead matters on a huge structure, opt out with $state.raw() for shallow reactivity only.
let snapshot = $state.raw({ huge: data })
// snapshot.huge.foo = 'bar' ← not tracked
snapshot = { ...snapshot, huge: { ...snapshot.huge, foo: 'bar' } } // ← tracked
2.2 $derived — lazy and cached
<script>
let count = $state(0)
let doubled = $derived(count * 2)
let expensive = $derived.by(() => {
let acc = 0
for (let i = 0; i < count; i++) acc += i
return acc
})
</script>
$derived is lazy and cached. Untouched, it does not compute. Dependencies unchanged, it does not recompute. Same semantics as Solid's createMemo and Vue's computed.
2.3 $effect — the explicit gate for side effects
<script>
let url = $state('/api/me')
$effect(() => {
const ctrl = new AbortController()
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(console.log)
return () => ctrl.abort() // ← cleanup
})
</script>
$effect runs after DOM mount and whenever dependencies change. Cleanup returns the same way as in React's useEffect. $effect.pre runs before DOM updates; $effect.root opens an explicitly-disposable effect tree.
The rule that bites everyone: do not write to $state inside $effect. That is how you summon infinite loops and synchronization explosions. Use $derived for anything that is "value derived from other values".
2.4 $props — the new component-input syntax
<!-- Svelte 4 -->
<script>
export let name
export let age = 0
export let onSave
</script>
<!-- Svelte 5 -->
<script>
let { name, age = 0, onSave, ...rest } = $props()
</script>
Multiple export let declarations collapse into one destructuring line. Defaults, rest props, renames — all in standard JS syntax. TypeScript flows naturally:
<script lang="ts">
interface Props {
name: string
age?: number
onSave: (id: string) => void
}
let { name, age = 0, onSave }: Props = $props()
</script>
Beyond these four: $bindable() makes a prop two-way; $host() returns the custom-element host; $inspect is a dev-time debugger rune.
3. Runes vs Signals vs ref vs observable vs useState — the matrix
Same counter in five reactivity models.
<!-- Svelte 5 / Runes -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
// Solid / Signals
function Counter() {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
}
<!-- Vue 3 / Composition + ref -->
<script setup>
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
watchEffect(() => console.log(count.value))
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
// MobX
import { makeAutoObservable } from 'mobx'
import { observer } from 'mobx-react-lite'
class Store {
count = 0
constructor() { makeAutoObservable(this) }
get doubled() { return this.count * 2 }
inc() { this.count += 1 }
}
const store = new Store()
const Counter = observer(() => (
<button onClick={() => store.inc()}>{store.count}</button>
))
// React 19 + React Compiler
function Counter() {
const [count, setCount] = useState(0)
// The Compiler memoizes — no manual useMemo needed
const doubled = count * 2
useEffect(() => console.log(count), [count])
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
Comparison matrix:
| Axis | Svelte 5 Runes | Solid Signals | Vue 3 ref/computed | MobX | React + Compiler |
|---|---|---|---|---|---|
| Read syntax | count (identifier) | count() (call) | count.value | store.count | count (destructured) |
| Write syntax | count++ | setCount(c => c + 1) | count.value++ | store.count++ | setCount(c => c + 1) |
| Derived | $derived(...) | createMemo(...) | computed(...) | getter (get x()) | implicit (Compiler) |
| Effect | $effect(...) | createEffect(...) | watchEffect(...) | autorun(...) | useEffect(..., [deps]) |
| Outside component | .svelte.ts | anywhere | anywhere | anywhere | hooks: no |
| Deep reactivity | yes (Proxy) | shallow (stores opt-in) | yes (Proxy) | yes (Proxy/getter) | shallow (ref equality) |
| Compile stage | compile-time | compile-time | runtime Proxy | runtime Proxy | compile + runtime hooks |
| Dependency array | none (auto) | none (auto) | none (auto) | none (auto) | manual or Compiler |
| TypeScript | natural | natural | natural | decorators possible | natural |
| Bundle weight | very small | very small | medium | medium | large |
The one-liner: Svelte 5 is "compile-time-rewritten signals." It is closest to Solid in spirit, but the read syntax is a plain identifier rather than a function call — so the code looks like ordinary JavaScript.
4. From $: to runes — the migration in practice
Good news: legacy mode still works. Svelte 5's compiler handles $: too. Set compilerOptions.runes = false in svelte.config.js, or put <svelte:options runes={false}/> at the top of a component, and your Svelte 4 code keeps compiling.
More good news: migrate one component at a time. A single project can have runes components and legacy components living together.
Five common transformation patterns.
4.1 let → $state
<!-- before -->
<script>
let count = 0
</script>
<!-- after -->
<script>
let count = $state(0)
</script>
The npx sv migrate svelte-5 codemod does most of these for you. Variables that are never reassigned do not get the rewrite — they did not need reactivity anyway.
4.2 $: foo = ... → $derived
<!-- before -->
<script>
let a = 1, b = 2
$: sum = a + b
</script>
<!-- after -->
<script>
let a = $state(1), b = $state(2)
let sum = $derived(a + b)
</script>
4.3 $: { ... } or $: if (...) → $effect
<!-- before -->
<script>
let count = 0
$: if (count > 10) document.title = `high: ${count}`
</script>
<!-- after -->
<script>
let count = $state(0)
$effect(() => {
if (count > 10) document.title = `high: ${count}`
})
</script>
4.4 Stores → runes in classes
The recommended 2026 pattern for state containers is a class in a .svelte.ts module.
// before — store
import { writable, derived } from 'svelte/store'
function createCart() {
const items = writable([])
const count = derived(items, ($i) => $i.length)
return {
items,
count,
add: (item) => items.update((arr) => [...arr, item]),
}
}
// after — runes in class (.svelte.ts)
export class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
export const cart = new Cart()
Call sites get shorter too. No more $cart.count prefix — it is cart.count.
4.5 on:click → onclick
Not strictly a runes change, but adjacent.
<!-- before -->
<button on:click={handler}>x</button>
<input on:input={handler} bind:value={text} />
<!-- after -->
<button onclick={handler}>x</button>
<input oninput={handler} bind:value={text} />
Standard HTML attribute names instead of colon directives. This pairs naturally with passing handlers via props.
5. SvelteKit 2 — data flow became explicit
A parallel shift happened in SvelteKit. The 2.0 release in early 2024 introduced two changes that are now the 2026 default.
5.1 Explicit invalidation
// before (SvelteKit 1)
export const load = async ({ fetch, params }) => {
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
// after (SvelteKit 2)
export const load = async ({ fetch, params, depends }) => {
depends('app:post')
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
You declare an invalidation key with depends, then trigger a reload with invalidate('app:post'). URL pattern matching used to do this implicitly — now it is explicit.
5.2 Form actions with enhance — with and without JS
Form actions live happily alongside runes. They are the cleanest demonstration of progressive enhancement on the modern web.
<script>
import { enhance } from '$app/forms'
let { form } = $props()
</script>
<form method="POST" use:enhance>
<input name="title" />
{#if form?.error}
<p>error: {form.error}</p>
{/if}
<button>save</button>
</form>
// +page.server.js
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const title = data.get('title')
if (!title) return { error: 'title required' }
await db.posts.create({ title })
return { success: true }
},
}
JS off? Standard form submit. JS on? enhance intercepts via fetch and gives you the SPA experience.
5.3 Universal vs server load
The distinction between +page.js (universal) and +page.server.js (server-only) is sharper with runes. A $state created in a universal load survives hydration. A server-only load returns plain JSON.
6. Real-world adoption — who is on runes
A year and a half after GA, runes have spread across nearly the entire Svelte ecosystem.
- SvelteKit's own starter templates are rune-first.
- Skeleton UI v3 (Tailwind 4 component library) rewrote on runes.
- shadcn-svelte migrated. No more
$storeprefix at call sites. - TanStack Query for Svelte v5 ships a rune-based API.
- HeyApi, Bits UI, Melt UI — all migrated or migrating.
On the user side: Spotify, Apple, and 1Password have public Svelte usage, and parts of Apple Music's web surface are known to be Svelte. In the State of JS 2024 survey, Svelte continued to lead on retention (the share of past users who would use it again), with React and Vue close behind on absolute usage.
7. What did we give up — the honest trade
Runes win on nearly every axis, but "everything is better" overstates it.
7.1 More keystrokes
let count = 0 became let count = $state(0). The brevity of "just JS" got slightly diluted. The trade-off — explicitness, consistency, scalability — is worth it, but it is a trade-off.
7.2 Proxy cost
$state wraps objects and arrays in Proxies. Large structures pay an overhead. Mitigation: use $state.raw() for hot paths, or swap immutably with a single assignment.
7.3 The .svelte.ts extension
Runes in plain modules require .svelte.js or .svelte.ts. The compiler will not touch a plain .ts. Minor, but a real footgun if you do not know.
7.4 The $effect trap
Writing to $state from $effect creates infinite loops. The ESLint rule (svelte/no-reactive-reassign) and compiler warnings catch most cases, but human attention is still required.
7.5 Learning curve during the overlap
For someone who knew Svelte 4, there is a period of holding two mental models. Even when runes are entirely better, half the existing material on the web teaches $:.
7.6 React's ecosystem is still bigger
Not a runes issue per se. In 2026, npm downloads, library count, and the job market all still favor React. Svelte wins on satisfaction, learning curve, bundle size, and runtime performance.
8. Is "React but better" still true in 2026?
A long-running claim: "Svelte = React but better." Worth revisiting now.
8.1 React 19 and the Compiler
React 19 cemented use(), Actions, and Server Components. The React Compiler reached GA, which means manual useMemo and useCallback largely went away. Automatic memoization is something Solid, Svelte, and Vue already had — React caught up.
Where React still wins:
- Ecosystem breadth. More libraries, more tooling, more jobs, more material.
- RSC depth. Svelte is going in a similar direction (server data plus client interactivity), but React has more layers of abstraction in place.
- Compatibility footprint. React Native, Expo, the default at very large companies.
8.2 Where Svelte 5 wins
- Bundle size. Not close. Router, reactivity, DOM handling combined fit in about a fifth of React + ReactDOM.
- Explicit reactivity. Runes pin intent into the code. React Compiler's pitch is "automatic"; runes' pitch is "declared."
- Inside-and-outside consistency. Runes work everywhere. React hooks only work inside components (or other hooks).
- DX simplicity. Directives like
bind:value,transition:fade, anduse:enhanceare still right there.
8.3 The answer is still "context"
"Svelte = React but better" is still true for developer experience. React is still bigger for ecosystem breadth and depth. Strip out hiring, existing code, and team familiarity, and Svelte 5's pitch is stronger than at GA — because runes patched the last weakness (reactivity outside components).
9. Side by side with Solid and Vue
Putting runes next to signals and refs, what is actually different?
9.1 vs Solid Signals
// Solid
const [count, setCount] = createSignal(0)
console.log(count()) // ← function call
setCount(1)
setCount(c => c + 1)
<!-- Svelte 5 -->
<script>
let count = $state(0)
console.log(count) // ← variable read
count = 1
count += 1
</script>
Solid makes signals function calls. The call is the unit of tracking — and you can see where tracking happens directly in the code. The cost is more keystrokes and slightly awkward ergonomics when signals live inside objects (Solid solves this with createStore).
Svelte 5 has the compiler do the same job. Identifier reads get rewritten into tracked calls. It reads like regular JS — at the cost of having to look at compiler output to confirm "is this tracked here?"
9.2 vs Vue 3 ref/computed
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
Vue uses .value to make ref access explicit. Inside templates, .value is auto-unwrapped. The closest model to runes — except Vue uses runtime Proxies rather than compile-time rewriting.
Vue 3.4+ ships an experimental Vapor mode (compile-time reactivity, à la Solid and Svelte). If Vapor generalizes, the gap between Vue refs and Svelte runes narrows further.
9.3 vs MobX
MobX is OOP-friendly. Call makeAutoObservable on a class instance and every field becomes observable, every getter becomes computed.
Svelte 5's "runes in classes" pattern is deliberately MobX-shaped.
// Svelte 5
class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
// MobX
class Cart {
items = []
constructor() { makeAutoObservable(this) }
get count() { return this.items.length }
add(item) { this.items.push(item) }
}
MobX uses decorators or a constructor call. Svelte 5 uses explicit runes. The semantics are nearly identical.
10. What the compiler actually emits
What does the compiler turn a runes file into? Trace a simple example.
<!-- source -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
Conceptually, the output looks roughly like this (real output is longer and lower-level):
import * as $ from 'svelte/internal/client'
function Component($$anchor) {
let count = $.state(0)
let doubled = $.derived(() => $.get(count) * 2)
$.user_effect(() => console.log($.get(count)))
const button = $.template('<button> </button>')()
$.event('click', button, () => $.set(count, $.get(count) + 1))
$.text(button.firstChild, () => $.get(count))
$.append($$anchor, button)
}
Key points:
- The identifier
countbecomes a signal cell. - Every read becomes
$.get(count); every write becomes$.set(count, ...). $derivedbecomes$.derived(thunk)— effectively the same implementation as Solid'screateMemo.$effectbecomes$.user_effect(thunk)— runs after mount and on every dependency change.
So: runes are a compiler trick that lets humans use signals without the call syntax. What differs from Svelte 4's magic is that the trigger is no longer position — it is an explicit token in the code.
11. Runes in larger apps — patterns and anti-patterns
11.1 Recommended patterns
- Domain logic in
.svelte.tsmodules. Keep components focused on presentation. - Classes with runes for state containers. Types flow more naturally than with stores.
$derivedfor derivations,$effectonly for side effects. Do not mix them.- Always return a cleanup from
$effect. Memory leaks live here. $state.rawfor shallow reactivity on large trees. Skips the Proxy cost.- Prop types as interfaces —
let { ... }: Props = $props(). $bindable()only where two-way binding is genuinely needed. Default to one-way.
11.2 Anti-patterns
- Writing to
$statefrom$effect. Infinite loop. Use$derivedfor derived values. - Module-level
$stateexported directly. SSR requests share it. Wrap in a factory function. - Mixing
$:and runes in the same component. Outside legacy mode, the compiler refuses. Pick one per component. - Wrapping huge objects with
$state. Proxy descent is expensive. Flatten, or use$state.raw. - Running stores and runes in parallel. Possible, but you hold two models in your head. Consolidate when you can.
- Fetching data in
$effect. Possible, but SvelteKit'sloadis better. Save$effectfor genuinely client-side side effects. - Leaving
$inspectin production. It is a dev-only debugging rune.
12. Tooling and ecosystem in the runes era
12.1 IDE and language server
The Svelte for VS Code extension fully understands runes. JetBrains WebStorm 2024.3+ does the same. Because runes are compiler keywords, the LSP knows their semantics.
12.2 ESLint and Prettier
eslint-plugin-svelte provides a runes ruleset. svelte/no-reactive-reassign and svelte/require-state-with-init are the load-bearing ones.
12.3 Testing
vitest plus @testing-library/svelte is the default. To test runes inside .svelte.ts, you often need flushSync() to force synchronous state updates.
12.4 Build
Vite 5 is the recommended bundler; Vite 6 is also compatible. SvelteKit sits on top with routing, SSR, and adapters. Bun supports Svelte 5, but the most mature SvelteKit adapters are Node, Vercel, Cloudflare, and Netlify.
12.5 Component libraries
- shadcn-svelte — unstyled, copy-paste components on runes.
- Skeleton v3 — runes plus Tailwind 4.
- Bits UI — headless components, runes-first.
- Melt UI — headless builders, fully migrated to runes.
13. The road ahead — after runes
The Svelte core team has publicly discussed three directions.
- Svelte 6. Stabilize the runes API; gradually retire legacy mode. Legacy is a deprecation candidate in 6, a removal candidate in 7.
- Compiler optimization. Ideas in the spirit of Vapor mode get pushed deeper into the existing Svelte 5 pipeline. Fine-grained updates already exist; finer text, attribute, and partial-tree optimizations are next.
- SvelteKit and the RSC boundary. Mirror the React Server Components split (server pieces, client pieces) more naturally on top of runes,
load, and form actions.
And the larger picture: standardized signals. The TC39 signals proposal is moving. If Svelte, Solid, Vue, and Angular share a primitive, runes become a surface syntax over a standard library — the same way ES modules and Promises stabilized after years of competing implementations.
Epilogue — A step toward explicitness
Svelte 4's let was beautiful. "Just JavaScript." Except it was not just JavaScript — the compiler changed its meaning. That secret tripped up newcomers, fought TypeScript, and could not leave the component.
Svelte 5's runes wrote that secret into the source. Reactivity lives where $state lives. It is longer. It is more honest. Solid, Vue, and MobX each reached the same destination in their own way; Svelte arrived in its own.
One sentence to take with you.
"Magic is fine. But the spell word should be visible to the human."
12-item checklist
- Is the
.sveltecomponent in runes mode (runes: trueor auto-detected)? - Do
letbindings actually need reactivity — only those wrapped in$state? - Are derived values clearly
$derived, side effects clearly$effect? - Are you avoiding writes to
$stateinside$effect? - Does every
$effectreturn its cleanup when it owns resources? - Are large objects using
$state.rawfor shallow reactivity? - Do rune modules outside components use the
.svelte.tsextension? - Are props declared as
let ... : Props = $props()? - Is
$bindable()reserved for genuinely two-way props? - When migrating from stores, are module-level instances SSR-safe (wrapped in a factory)?
- Are SvelteKit
loadinvalidations explicit viadependsandinvalidate? - Is the new code free of legacy residue (
$:,on:click,export let)?
10 anti-patterns
- Writing to
$stateinside$effect— infinite loop. - Exporting module-level
$statedirectly — SSR sharing bug. - Wrapping huge objects in
$state— Proxy cost. - Mixing
$:and runes in one component. - Letting stores and runes own the same domain.
- Fetching data in
$effectinstead ofload. - Shipping
$inspector$state.snapshotdebug code to production. - Overusing
$bindable()for one-way data. - Writing runes outside
.svelteor.svelte.tsfiles. - Silencing the compiler warnings during the migration.
Next-post candidates
- SvelteKit 2 data flow deep dive — what
load, actions, andenhancereally mean together. - Tracking TC39 Signals — the shared future for Svelte, Solid, Vue, and Angular.
- Svelte vs Astro vs SolidStart — choosing a stack for content sites in 2026.
"Reactivity is not magic, it is a tool. The tool's name belongs in the code."
— Svelte 5 Runes, end.
References
- Svelte official site
- Svelte 5 announcement post
- Svelte 5 migration guide
- Runes documentation
$statereference$derivedreference$effectreference$propsreference- SvelteKit documentation
- SvelteKit 2 migration
- Solid — Reactivity guide
- Vue 3 Reactivity API
- Vue Vapor mode RFC
- MobX official
- React Compiler documentation
- TC39 Signals proposal
- State of JS 2024
- shadcn-svelte
- Skeleton UI
- Bits UI
- Melt UI