Skip to content

Commit 7e46f10

Browse files
authored
Merge pull request #8 from sveltejs/reactive-declarations
Reactive declarations
2 parents 47c1c9a + 24f94c2 commit 7e46f10

File tree

1 file changed

+355
-0
lines changed

1 file changed

+355
-0
lines changed

text/0003-reactive-declarations.md

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
- Start Date: 2018-11-28
2+
- RFC PR: [#8](https://github.com/sveltejs/rfcs/pull/8)
3+
- Svelte Issue: (leave this empty)
4+
5+
# Reactive declarations
6+
7+
## Summary
8+
9+
This RFC proposes an implementation of the 'destiny operator' inside Svelte components, using a little-known and rarely-used JavaScript feature called [labels](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label):
10+
11+
```html
12+
<script>
13+
let count = 1;
14+
let doubled;
15+
let quadrupled;
16+
17+
$: doubled = count * 2;
18+
$: quadrupled = doubled * 2;
19+
</script>
20+
21+
<p>Twice {count} is {doubled}; twice that is {quadrupled}</p>
22+
23+
<button on:click="{() => count += 1}">+1</button>
24+
```
25+
26+
27+
## Motivation
28+
29+
In [RFC 1](https://github.com/sveltejs/rfcs/blob/reactive-assignments/text/0001-reactive-assignments.md), we introduced the idea of *reactive assignments*, in which assigning to a component's local variables...
30+
31+
```js
32+
count += 1;
33+
```
34+
35+
...causes the component to update.
36+
37+
As an indirect consequence of that change, we're able to get rid of all the boilerplate associated with Svelte 2 components, greatly simplifying both the compiler and the user's application code. But it also means getting rid of [computed properties](https://svelte.technology/guide#computed-properties), which are a powerful and convenient mechanism for push-based reactivity:
38+
39+
```html
40+
<p>Twice {count} is {doubled}; twice that is {quadrupled}</p>
41+
42+
<button on:click="{() => count += 1}">+1</button>
43+
44+
<script>
45+
export default {
46+
data() {
47+
return { count: 1 };
48+
},
49+
50+
computed: {
51+
doubled: ({ count }) => count * 2,
52+
quadrupled: ({ doubled }) => doubled * 2
53+
}
54+
};
55+
</script>
56+
```
57+
58+
The useful thing about computed properties is that they are only recalculated when their inputs change, side-stepping a common performance problem that affects some frameworks in which derived values must be recalculated on every render. Unlike some other frameworks that implement computed properties, the compiler is able to build a dependency graph of computed properties at *compile time*, enabling it to sort them topologically and generate highly efficient code that doesn't depend on expensive *run time* dependency tracking.
59+
60+
RFC 1 initially glossed over the loss of computed properties, suggesting that we could simply replace them with functions:
61+
62+
```html
63+
<script>
64+
let count = 1;
65+
const doubled = () => count * 2;
66+
const quadrupled = () => doubled() * 2;
67+
</script>
68+
69+
<p>Twice {count} is {doubled()}; twice that is {quadrupled()}</p>
70+
71+
<button on:click="{() => count += 1}">+1</button>
72+
```
73+
74+
But in this scenario, `doubled` must be called twice (once directly, once via `quadrupled`). Not only that, but we have to trace which values are read when `doubled` and `quadrupled` are called so that we know when we need to update them; in some cases it would be necessary to bail out of that optimisation and call those functions whenever *anything* changed. In more realistic examples, this results in a lot of extra work relative to Svelte 2.
75+
76+
The only way to avoid that work is with a [Sufficiently Smart Compiler](http://wiki.c2.com/?SufficientlySmartCompiler). We need an alternative.
77+
78+
79+
## Detailed design
80+
81+
In [What is Reactive Programming?](http://paulstovell.com/blog/reactive-programming), Paul Stovell introduces the 'destiny operator':
82+
83+
```
84+
var a = 10;
85+
var b <= a + 1;
86+
a = 20;
87+
Assert.AreEqual(21, b);
88+
```
89+
90+
If we could use the destiny operator in components, the Svelte 3 compiler would be able to do exactly what it does with Svelte 2 computed properties — update `b` whenever `a` changes.
91+
92+
Unfortunately we can't, because that would be invalid JavaScript, and it's important for many reasons that everything inside a component's `<script>` block be valid JS. Is there a piece of syntax we could (ab)use in its place? Happily, there is — the *label*:
93+
94+
```js
95+
let a = 10;
96+
let b;
97+
98+
$: b = a + 1;
99+
```
100+
101+
This tells the compiler 'run the `b = a + 1` statement whenever `a` changes'.
102+
103+
It's only fair to acknowledge that this is *weird*. Aside from the unfamiliarity of labels (for most of us), we're used to statements running in order, top-to-bottom.
104+
105+
But it's not quite as weird as it might first seem. *Declarations* don't run in order; a class can extend a function constructor that is defined later, and you can freely put your exports at the top of your module and your imports at the bottom if it makes you happy. Seen in this light, `$: b = a + 1` is a **declaration of equivalence** between `b` and the expression `a + 1`, rather than a statement.
106+
107+
And the concept isn't new — framework designers have invented all sorts of ways to approximate the destiny operator. MobX's `computed` function and decorator, RxJS Observables, and the computed properties in Svelte and Vue are all related ideas. The main difference with this approach is that it's syntactically much lighter, and depends on compile-time dependency tracking rather than (for example) wrapping everything in proxies and accessors.
108+
109+
In fact, it's similar to [Observable](https://beta.observablehq.com/), a platform for reactive programming notebooks. In both cases, expressions run repeatedly (but conservatively) in [topological order](https://beta.observablehq.com/@mbostock/how-observable-runs). The most commonly used analogy is that of a spreadsheet, where cells with formulas automatically stay consistent with the cells that they reference, without needing to update the whole dang worksheet.
110+
111+
> The choice of the `$` character, which overwhelmingly beat other options in a poll, is for three reasons: it's visually distinctive, easy to type, and mirrors the use of the `$` prefix in templates to mark values as reactive, as discussed below.
112+
113+
114+
### The mechanics of reactive declarations
115+
116+
For one thing, they're not *actually* declarations — we're simply marking statements that should be re-run periodically. The compiler output for the example at the top of this document might resemble the following:
117+
118+
```js
119+
function init($$self, $$make_dirty) {
120+
let count = 1;
121+
let doubled;
122+
let quadrupled;
123+
124+
function handle_click() {
125+
count += 1;
126+
$$make_dirty('count');
127+
}
128+
129+
$$self.get = () => ({ count, doubled, quadrupled, handle_click });
130+
131+
$$self.synchronize = $$dirty => {
132+
if ($$dirty.count) doubled = count * 2; $$dirty.doubled = true;
133+
if ($$dirty.doubled) quadrupled = doubled * 2; $$dirty.quadrupled = true;
134+
};
135+
}
136+
```
137+
138+
(This code is illustrative; it isn't necessarily optimal.)
139+
140+
A consequence of this design is that we can update multiple computed values in a single go. For example, one way to compute values for an SVG scatterplot involves iterating over data multiple times...
141+
142+
```js
143+
$: x_scale = get_scale([min_x, max_x], [0, width]);
144+
$: y_scale = get_scale([min_y, max_y], [height, 0]);
145+
146+
$: min_x = Math.min(...points.map(p => p.x));
147+
$: max_x = Math.max(...points.map(p => p.x));
148+
$: min_y = Math.min(...points.map(p => p.y));
149+
$: max_y = Math.max(...points.map(p => p.y));
150+
```
151+
152+
...but we could do it more efficiently (if more verbosely) in a single pass:
153+
154+
```js
155+
$: x_scale = get_scale([min_x, max_x], [0, width]);
156+
$: y_scale = get_scale([min_y, max_y], [height, 0]);
157+
158+
$: {
159+
min_x = Infinity; max_x = -Infinity; min_y = Infinity; max_y = -Infinity; // reset
160+
161+
points.forEach(point => {
162+
if (point.x < min_x) min_x = point.x;
163+
if (point.x > max_x) max_x = point.x;
164+
if (point.y < min_y) min_y = point.y;
165+
if (point.y > max_y) max_y = point.y;
166+
});
167+
}
168+
```
169+
170+
Another consequence is that it's straightforward to include side-effects (`$: console.log(foo)`). Reasonable people can disagree about whether that is to be encouraged or not.
171+
172+
173+
### Timing
174+
175+
Since reactive declarations are ordered topologically, we probably don't want them to run immediately in source order upon instantiation.
176+
177+
We also don't want to run them immediately upon every change. Recalculating `foo` after `bar` is updated...
178+
179+
```js
180+
$: foo = expensivelyRecompute(bar, baz);
181+
182+
function handleClick() {
183+
bar += 1;
184+
baz += 1;
185+
}
186+
```
187+
188+
...would be wasteful. Instead, reactive declarations should all be updated in one go, at the beginning of the component's update cycle.
189+
190+
**This does highlight a limitation** — we're not talking about a 'true' destiny operator, in which the intermediate value of `foo` *would* be available if you were to access it immediately after setting `bar`. Reactive declarations are *eventually* consistent with their inputs. This would be an important thing to communicate clearly.
191+
192+
It also raises the question of what should happen if reactive declaration inputs are updated inside a `beforeUpdate` handler, immediately after synchronization has happened.
193+
194+
195+
### Read-only values
196+
197+
In Svelte 2, computed properties are read-only — attempting to write to them throws an error, but only at run time and only in dev mode. With this proposal we can do better: having identified computed values, we can treat any assignments to them (e.g. in an event handler) as illegal and raise a compile-time warning or (🐃) error.
198+
199+
200+
### Reactive stores
201+
202+
[RFC 2](https://github.com/sveltejs/rfcs/blob/svelte-observables/text/0002-observables.md) introduced a proposal for reactive stores.
203+
204+
Briefly, the idea is that a reactive store exposes a `subscribe` method that can be used to track a value over time; *writable* stores would also expose methods like `set` and `update`. This allows application state to be stored outside the component tree, where appropriate. Inside templates, stores can be referenced with a `$` prefix that exposes their value:
205+
206+
```html
207+
<script>
208+
import { todos, user } from './stores.js';
209+
</script>
210+
211+
<h1>Hello {$user.name}!</h1>
212+
213+
{#each $todos as todo}
214+
<p>{todo.description}</p>
215+
{/each}
216+
```
217+
218+
One limitation of reactive stores is that it's difficult to mix them with a component's local state. For example, if we wanted a filtered view of those todos, we can't simply derive a new store that uses a local filter variable —
219+
220+
```html
221+
<script>
222+
import { derive } from 'svelte/store';
223+
import { todos, user } from './stores.js';
224+
225+
let hideDone = false;
226+
const filtered = derive(todos, t => hideDone ? !t.done : true);
227+
</script>
228+
229+
<h1>Hello {$user.name}!</h1>
230+
231+
<label>
232+
<input type=checkbox bind:checked={hideDone}>
233+
hide done
234+
</label>
235+
236+
{#each $filtered as todo}
237+
<p>{todo.description}</p>
238+
{/each}
239+
```
240+
241+
— because `filtered` doesn't have a way to know when `hideDone` changes. Instead, we'd need to create a new store:
242+
243+
```diff
244+
<script>
245+
- import { derive } from 'svelte/store';
246+
+ import { writable, derive } from 'svelte/store';
247+
import { todos, user } from './stores.js';
248+
249+
- let hideDone = false;
250+
- const filtered = derive(todos, t => hideDone ? !t.done : true);
251+
+ const hideDone = writable(false);
252+
+ const filtered = derive([todos, hideDone], ([t, hideDone]) => hideDone ? !t.done : true);
253+
</script>
254+
255+
<h1>Hello {$user.name}!</h1>
256+
257+
<label>
258+
- <input type=checkbox bind:checked={hideDone}>
259+
+ <input type=checkbox checked={$hideDone} on:change="{e => hideDone.set(e.target.checked)}">
260+
hide done
261+
</label>
262+
263+
{#each $filtered as todo}
264+
<p>{todo.description}</p>
265+
{/each}
266+
```
267+
268+
Reactive declarations offer an alternative, if we allow the same treatment of values with the `$` prefix:
269+
270+
```diff
271+
<script>
272+
import { derive } from 'svelte/store';
273+
import { todos, user } from './stores.js';
274+
275+
let hideDone = false;
276+
- const filtered = derive(todos, t => hideDone ? !t.done : true);
277+
+ let filtered;
278+
+ $: filtered = $todos.filter(t => hideDone ? !t.done : true);
279+
</script>
280+
281+
<h1>Hello {$user.name}!</h1>
282+
283+
<label>
284+
<input type=checkbox bind:checked={hideDone}>
285+
hide done
286+
</label>
287+
288+
-{#each $filtered as todo}
289+
+{#each filtered as todo}
290+
<p>{todo.description}</p>
291+
{/each}
292+
```
293+
294+
The obvious problem with this is that `$todos` isn't defined anywhere in the `<script>`, which is potentially confusing to humans and computers alike. This is possibly solvable with a combination of documentation and linting rules.
295+
296+
297+
## How we teach this
298+
299+
This shouldn't be the first thing that people encounter when learning Svelte — it's sufficiently surprising that a lot of people would be turned off before understanding the value proposition. Instead, the 'vanilla' alternative — updating everything manually in a `beforeUpdate` function — should probably be taught first, so that the concept ('and now, let's have the compiler do that for us, except more efficiently!') is already familiar.
300+
301+
When discussing reactive programming, it's useful to refer to existing implementations of the idea, including spreadsheets.
302+
303+
304+
## Drawbacks
305+
306+
A lot of people seeing this proposal, particularly those who have an irrational dislike of anything that isn't pure JavaScript, will instinctively recoil.
307+
308+
We shouldn't seek to appease people whose opinions are already fixed. But it *is* fair to acknowledge that this proposal will surprise people, and steepen Svelte 3's (admittedly shallow) learning curve. It may be that the cost of adding this feature outweighs the benefit for that reason; the only way to know is to gauge people's reactions to this RFC. (So far, the response has been encouraging, with most of the concern centering on how it will be received beyond the existing Svelte community.)
309+
310+
Elements that may be particularly confusing to learn:
311+
312+
* that reactive declarations don't run on initialisation (though we could change that)
313+
* that reactive declarations don't run immediately upon reassignment of their inputs, but rather as part of the update cycle
314+
* the use of the `$` prefix to unwrap reactive stores
315+
316+
We also need a well-considered answer to the question about what should happen when inputs are reassigned during `beforeUpdate`.
317+
318+
319+
## Alternatives
320+
321+
The 'do nothing' alternative is to rely on function calls — either making their dependencies explicit, or attempting to trace their dependencies. Absent a Sufficiently Smart Compiler, this risks creating significant computational overhead.
322+
323+
Or, we could rely on users to recompute values themselves in `beforeUpdate`. This is unergonomic, and risks either unnecessary work (recomputing values when inputs haven't changed) or bugs (failing to do so when they have).
324+
325+
Some people propose using magic functions instead, transforming calls to those functions into the code we've already seen:
326+
327+
```js
328+
import { compute } from 'svelte';
329+
330+
let a = 1;
331+
let b = compute(() => a * 2);
332+
```
333+
334+
I personally find this very confusing. Since `compute` is just a function, I would expect to be able to compose it, curry it, pass it outside the component and so on, none of which are true.
335+
336+
A final possibility is to make everything a property of a class (rather than standalone variables) and use run time proxy/accessor magic, a la MobX etc...
337+
338+
```js
339+
@observable class MyStuff {
340+
@observe a = 1;
341+
342+
@computed get b() {
343+
return this.a + 1;
344+
}
345+
}
346+
347+
const stuff = new MyStuff();
348+
```
349+
350+
...but this involves considerable overhead, and is massively less ergonomic.
351+
352+
353+
## Unresolved questions
354+
355+
The details of when reactive declarations should be synchronized is up for debate — one suggestion is that it should happen *after* `beforeUpdate`.

0 commit comments

Comments
 (0)