-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
docs: explain how to pass reactivity across boundaries #14311
base: main
Are you sure you want to change the base?
Changes from all commits
7bcbf9d
543e455
11dbd6a
f928779
ff28761
3972524
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -64,9 +64,35 @@ todos.push({ | |||||||||
|
||||||||||
> [!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: | ||||||||||
|
||||||||||
```svelte | ||||||||||
<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 also use `$state` in class fields (whether public or private): | ||||||||||
You can use `$state` in class fields (whether public or private): | ||||||||||
|
||||||||||
```js | ||||||||||
// @errors: 7006 2554 | ||||||||||
|
@@ -85,7 +111,7 @@ class Todo { | |||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
> [!NOTE] The compiler transforms `done` and `text` into `get`/`set` methods on the class prototype referencing private fields. | ||||||||||
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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The second part sounds like it could be a note
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I avoided the note because I felt it disrupts the reading flow for no sufficient reason |
||||||||||
|
||||||||||
## `$state.raw` | ||||||||||
|
||||||||||
|
@@ -111,6 +137,165 @@ person = { | |||||||||
|
||||||||||
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`]($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`]($derived) as "just JavaScript", and reuse the knowledge of how normal JavaScript variables work when crossing boundaries. Take the following example: | ||||||||||
|
||||||||||
```js | ||||||||||
// @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: | ||||||||||
|
||||||||||
```js | ||||||||||
// @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: | ||||||||||
|
||||||||||
```js | ||||||||||
// @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: | ||||||||||
|
||||||||||
```js | ||||||||||
// @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`]($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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was trying to split the second part into a separate sentence to avoid it being a run-on and realized we probably don't need the second part at all
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to emphasize what the runes actually do instead. |
||||||||||
|
||||||||||
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. | ||||||||||
|
||||||||||
```js | ||||||||||
// @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 | ||||||||||
``` | ||||||||||
|
||||||||||
```js | ||||||||||
// @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 | ||||||||||
``` | ||||||||||
|
||||||||||
```js | ||||||||||
// @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. | ||||||||||
|
||||||||||
```js | ||||||||||
// @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`: | ||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having a bit of a hard time parsing this. What does it include besides classes? Does it mean that closures don't work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does closures have to do with this? This is about what gets proxified when you pass it to $state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ohhh. I didn't read it that way at all. I thought it was saying
$state
that has been created can't be created across a boundary. But you're talking about the$state(...)
initializer? Maybe something like this then: