Skip to content
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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 187 additions & 2 deletions documentation/docs/02-runes/02-$state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boundaries that are not simple arrays or objects

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?

Copy link
Member Author

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

Copy link
Member

@benmccann benmccann Nov 22, 2024

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:

Suggested change
Since `$state` stops at boundaries that are not simple arrays or objects, the following will not trigger any reactivity:
Since the `$state` initializer deeply creates reactive state only when encountering 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
Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second part sounds like it could be a note

Suggested change
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.
Under the hood, the compiler transforms `done` and `text` into `get`/`set` methods on the class prototype referencing private fields.
> [!NOTE] Class properties are _not_ enumerable due to the compiler transforms mentioned just above.

Copy link
Member Author

Choose a reason for hiding this comment

The 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`

Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The 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
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.
Notice how we didn't use _any_ Svelte specifics, this is just regular JavaScript semantics. `$state`, `$state.raw`, and [`$derived`]($derived) doesn't change this.

Copy link
Member Author

Choose a reason for hiding this comment

The 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`:
Expand Down
4 changes: 4 additions & 0 deletions documentation/docs/02-runes/03-$derived.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ In essence, `$derived(expression)` is equivalent to `$derived.by(() => expressio
Anything read synchronously inside the `$derived` expression (or `$derived.by` function body) is considered a _dependency_ of the derived state. When the state changes, the derived will be marked as _dirty_ and recalculated when it is next read.

To exempt a piece of state from being treated as a dependency, use [`untrack`](svelte#untrack).

## Passing `$derived` across boundaries

The same rules as for [passing `$state` across boundaries]($state#Passing-$state-across-boundaries) apply.
4 changes: 3 additions & 1 deletion documentation/docs/06-runtime/02-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The context is then available to children of the component (including slotted co

> [!NOTE] `setContext`/`getContext` must be called during component initialisation.

Context is not inherently reactive. If you need reactive values in context then you can pass a `$state` object into context, whose properties _will_ be reactive.
Context is not inherently reactive. If you need reactive values in context then you can pass a `$state` object into context, whose properties _will_ be reactive (for more info see ["passing `$state` across boundaries"]($state#Passing-$state-across-boundaries)).

```svelte
<!--- file: Parent.svelte --->
Expand All @@ -72,6 +72,8 @@ Context is not inherently reactive. If you need reactive values in context then

let value = $state({ count: 0 });
setContext('counter', value);
// careful: reassignments will _not_ change the value inside context:
// value = { count: 0 }
</script>

<button onclick={() => value.count++}>increment</button>
Expand Down
Loading