✍️ 필사 모드: Svelte 5 Runes — The Reactivity Rewrite, and a Head-to-Head with Solid, Vue, MobX, and the React Compiler (2026 Deep Dive) (english)
EnglishPrologue — 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
현재 단락 (1/450)
Svelte 3 was a promise. "Reactivity should be a compiler concern; the runtime should be tiny." `let ...