8.4 KiB
title |
---|
$state |
The $state
rune allows you to create reactive state, which means that your UI reacts when it changes.
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
clicks: {count}
</button>
Unlike other frameworks you may have encountered, there is no API for interacting with state — count
is just a number, rather than an object or a function, and you can update it like you would update any other variable.
Deep state
If $state
is used with an array or a simple object, the result is a deeply reactive state proxy. Proxies allow Svelte to run code when you read or write properties, including via methods like array.push(...)
, triggering granular updates.
[!NOTE] Classes like
Set
andMap
will not be proxied, but Svelte provides reactive implementations for various built-ins like these that can be imported fromsvelte/reactivity
.
State is proxified recursively until Svelte finds something other than an array or simple object. In a case like this...
let todos = $state([
{
done: false,
text: 'add more todos'
}
]);
...modifying an individual todo's property will trigger updates to anything in your UI that depends on that specific property:
// @filename: ambient.d.ts
declare global {
const todos: Array<{ done: boolean, text: string }>
}
// @filename: index.js
// ---cut---
todos[0].done = !todos[0].done;
If you push a new object to the array, it will also be proxified:
// @filename: ambient.d.ts
declare global {
const todos: Array<{ done: boolean, text: string }>
}
// @filename: index.js
// ---cut---
todos.push({
done: false,
text: 'eat lunch'
});
[!NOTE] When you update properties of proxies, the original object is not mutated.
Since $state
stops at boundaries that are not simple arrays or objects, the following will not trigger any reactivity:
<script>
class Todo {
done = false;
text;
constructor(text) {
this.text = text;
}
}
let todo = $state(new Todo('Buy groceries'));
</script>
<button onclick={
// this won't trigger a rerender
todo.done = !todo.done
}>
[{todo.done ? 'x' : ' '}] {todo.text}
</button>
You can however use $state
inside the class to make it work, as explained in the next section.
Classes
You can use $state
in class fields (whether public or private):
// @errors: 7006 2554
class Todo {
done = $state(false);
text = $state();
constructor(text) {
this.text = text;
}
reset() {
this.text = '';
this.done = false;
}
}
Under the hood, the compiler transforms done
and text
into get
/set
methods on the class prototype referencing private fields. That means the properties are not enumerable.
$state.raw
In cases where you don't want objects and arrays to be deeply reactive you can use $state.raw
.
State declared with $state.raw
cannot be mutated; it can only be reassigned. In other words, rather than assigning to a property of an object, or using an array method like push
, replace the object or array altogether if you'd like to update it:
let person = $state.raw({
name: 'Heraclitus',
age: 49
});
// this will have no effect
person.age += 1;
// this will work, because we're creating a new person
person = {
name: 'Heraclitus',
age: 50
};
This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that raw state can contain reactive state (for example, a raw array of reactive objects).
Passing $state
across boundaries
Since there's no wrapper around $state
, $state.raw
, or $derived
, you have to be aware of keeping reactivity alive when passing it across boundaries — e.g. when you pass a reactive object into or out of a function. The most succinct way of thinking about this is to treat $state
, $state.raw
, and $derived
as "just JavaScript", and reuse the knowledge of how normal JavaScript variables work when crossing boundaries. Take the following example:
// @errors: 7006
function createTodo(initial) {
let text = initial;
let done = false;
return {
text,
done,
log: () => console.log(text, done)
}
}
const todo = createTodo('wrong');
todo.log(); // logs "'wrong', false"
todo.done = true;
todo.log(); // still logs "'wrong', false"
The value change does not propagate back into the function body of createTodo
, because text
and done
are read at the point of return, and are therefore a fixed value. To make that work, we have to bring the read and write into the scope of the function body. This can be done via getter/setters or via function calls:
// @errors: 7006
function createTodo(initial) {
let text = initial;
let done = false;
return {
// using getter/setter
get text() { return text },
set text(v) { text = v },
// using functions
isDone() { return done },
toggle() { done = !done },
// log
log: () => console.log(text, done)
}
}
const todo = createTodo('right');
todo.log(); // logs "'right', false"
todo.text = 'changed'; // invokes the setter
todo.toggle(); // invokes the function
todo.log(); // logs "'changed', true"
What you could also do is to instead create an object and return that as a whole. While the variable itself is fixed in time, its properties are not, and so they can be changed from the outside and the changes are observable from within the function:
// @errors: 7006
function createTodo(initial) {
const todo = { text: initial, done: false }
return {
todo,
log: () => console.log(todo.text, todo.done)
}
}
const todo = createTodo('right');
todo.log(); // logs "'right', false"
todo.todo.done = true; // mutates the object
todo.log(); // logs "'right', true"
Classes are similar, their properties are "live" due to the this
context:
// @errors: 7006
class Todo {
done = false;
text;
constructor(text) {
this.text = text;
}
log() {
console.log(this.done, this.text)
}
}
const todo = new Todo('right');
todo.log(); // logs "'right', false"
todo.done = true;
todo.log(); // logs "'right', true"
Notice how we didn't use any Svelte specifics, this is just regular JavaScript semantics. $state
and $state.raw
(and $derived
) don't change these, they just add reactivity on top, so that when you change a variable something can happen in reaction to it.
As a consequence, the answer to preserving reactivity across boundaries is to use getters/setters or functions (in case of $state
, $state.raw
and $derived
), an object with mutable properties (in case of $state
), or a class with reactive properties.
// @errors: 7006
/// file: getters-setters-functions.svelte.js
function doubler(count) {
const double = $derived(count() * 2)
return {
get current() { return double }
};
}
let count = $state(0);
const double = doubler(() => count);
$effect(() => console.log(double.current)); // $effect logs 0
count = 1; // $effect logs 2
// @errors: 7006
/// file: mutable-object.svelte.js
function logger(value) {
$effect(() => console.log(value.current));
}
let count = $state({ current: 0 });
logger(count); // $effect logs 0
count.current = 1; // $effect logs 1
// @errors: 7006
/// file: class.svelte.js
function logger(counter) {
$effect(() => console.log(counter.count));
}
class Counter {
count = $state(0);
increment() { this.count++; }
}
let counter = new Counter();
logger(counter); // $effect logs 0
counter.increment(); // $effect logs 1
For the same reasons, you should not destructure reactive objects — their value is read at that point in time, and not updated anymore from inside whatever created it.
// @errors: 7006
class Counter {
count = $state(0);
increment = () => { this.count++; }
}
// don't do this
let { count, increment } = new Counter();
count; // 0
increment();
count; // still 0
// do this instead
let counter = new Counter();
counter.count; // 0
counter.increment();
counter.count; // 1
$state.snapshot
To take a static snapshot of a deeply reactive $state
proxy, use $state.snapshot
:
<script>
let counter = $state({ count: 0 });
function onclick() {
// Will log `{ count: ... }` rather than `Proxy { ... }`
console.log($state.snapshot(counter));
}
</script>
This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as structuredClone
.