-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
docs: explain how to pass reactivity across boundaries #14311
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
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. | ||||||
Rich-Harris marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
|
||||||
| ## `$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. | ||||||
|
Member
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
Member
Author
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
Uh oh!
There was an error while loading. Please reload this page.
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
$statethat has been created can't be created across a boundary. But you're talking about the$state(...)initializer? Maybe something like this then: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 think this whole block should go in the next section. I'm generally a bit annoyed when docs show me what not to do as a way of teeing up what I should do — just show me the right way in the first place!
In this case, we can demonstrate the Svelte way of using classes, and then reiterate the point that's made at the top of the 'Deep state' section...
...adjusted to emphasise that things that aren't arrays or simple objects won't be proxified.