State that the UI follows automatically is the core of every Svelte component.
In Svelte 5, reactivity is explicit through runes, compiler keywords that start with $. The first is `$state`:
let count = $state(0);count is a normal variable: read it as count, update it with count++ or count = 5, and every place in the markup that uses it updates. No .value, no setter function.
When the value is an object or array, $state makes it deeply reactive by wrapping it in a Proxy, so mutations work too: todos.push(item), todos[0].done = true and user.name = 'Ada' all update the UI.
Things to know:
- Plain
let x = 0without the rune is not reactive in Svelte 5 components: the UI won't follow reassignments. - Updates are applied in a batch after your code finishes; the DOM is not updated synchronously.
await tick()waits for it. - Destructuring copies values:
let { name } = usergives a non-reactive snapshot. - Classes can use runes in fields:
class Todo { done = $state(false) }. console.logof a state proxy shows a Proxy; use `$inspect(value)` (dev only) to log the value whenever it changes, or$state.snapshot(value)to get a plain copy.
Runes are only valid in .svelte files and .svelte.js/.svelte.ts modules, and only at the top level of a declaration, not inside arbitrary expressions.
Example
<script>
let count = $state(0);
let todos = $state([
{ text: 'learn runes', done: false },
]);
let plain = 0; // NOT reactive
function add() {
todos.push({ text: `todo ${todos.length + 1}`, done: false }); // mutation works
}
$inspect(todos); // logs on every change (dev only)
</script>
<button onclick={() => count++}>clicks: {count}</button>
<button onclick={() => plain++}>plain: {plain}</button> <!-- stays 0 on screen -->
<button onclick={add}>add todo</button>
{#each todos as todo}
<label><input type="checkbox" bind:checked={todo.done} /> {todo.text}</label>
{/each}Remember
let x = $state(initial)makes a reactive variable updated by plain assignment- Objects and arrays in
$stateare deeply reactive proxies; mutate them directly - Use
$inspector$state.snapshotto log state, not raw console.log of proxies
Watch out for
- Declaring
let count = 0without$stateand wondering why the screen never updates.