Svelte 5 signals fix its glitchy and inconsistent reactivity

Aug 15, 2024

Svelte is a frontend JS framework that's known in part for its terse and ergonomic reactivity. Reactivity is how changes to data synchronize with the UI and other downstream data. In 2019 Svelte 3 introduced a reactivity design that caught a lot of attention for its ease and apparent simplicity. Svelte rapidly rose to prominence in its crowded space, and in surveys it has consistently been well-liked by its users. But 5 years later, Svelte 5 is again rethinking its reactivity, this time with signals and runes syntax - why? I thought we loved it?

To many, Svelte 3 was a fresh and attractive answer to the hard problem of building reactive UIs. It chose plain HTML as its starting point, and added some of the simplest possible syntax to make its semantics reactive. Few frameworks look better in trivial examples:

<script>
  let count = 1;
</script>

<button on:click={() => count++}>
  {count}
</button>

This terse and fairly efficient reactivity was one of the main reasons I switched to Svelte full-time in 2019 after 5 years of React. But while its reactivity looks simple on the surface, problems appear in nontrivial cases. There be dragons beyond this inviting facade.

This post describes two specific problems with Svelte 3's reactivity and how they're fixed in Svelte 5 with signals, the primitive behind its runes. Svelte 5 fixes many other reactivity issues, and also improves its composability and other aspects, but we'll focus on two things:

  1. reactive statement inconsistency
  2. derived store glitches

Svelte 5, largely inspired by Solid, uses signals to improve its reactivity and fix these two problems, among others. In 2024 signals are being adopted by most popular frontend frameworks, React being the notable exception with its purely functional roots. Signals are popular enough to have a broadly-backed proposal to standardize them in the JS language.

Inconsistent reactive statements

Inconsistency is a term I'm borrowing from Ryan Carniato (see this video for example) that describes the situation where you update the dependencies of a derived value (in this case, derived via a reactive statement), and then synchronously read the derived value, but you get the old value. For example:

let a = 1;
$: b = a * 2;
setTimeout(() => {
	console.log(b); // 2
	a = 3;
	console.log(b); // 2 ???
});

REPL

It seems logical that b would be 6 after reassigning a to 3, not still 2. The problem is the reactive statement starting with $: does not re-run immediately - its update is deferred to the next tick of the JS runtime. The same problem causes b to be undefined during initialization:

let a = 1;
$: b = a * 2;
console.log(b); // undefined ???

REPL

Svelte 5 fixes this problem with runes, which use signals under the hood:

let a = $state(1);
const b = $derived(a * 2);
console.log(b); // 2 :)
setTimeout(() => {
	a = 3;
	console.log(b); // 6 :)
});

REPL

This behavior of reactive statements wasn't unthoughtful design. It avoids numerous issues that result from eager evaluation, and it was an exploration of the limits of static analysis, which was a promising direction to improve the ergonomics and efficiency of reactive systems, as Svelte creator Rich Harris discusses in this video.

Signals straightforwardly resolve this wicked problem of reading stale derived values by consistently having the latest value.

Glitchy derived stores

In reactive programming, glitches occur when a program computes temporary values that make no sense. For example if you have fullname that combines a firstname and lastname, an example glitch might involve computing a full name using the first and last names of two different people, even when the code updates variables sequentially:

// pseudo-code
firstname = 'Alan'
lastname  = 'Turing'
fullname  = firstname + ' ' + lastname
firstname = 'Barbara' // does code see 'Barbara Turing' here? if so it's a glitch
lastname  = 'Liskov'

Glitches can be a source of:

  • bugs: what happens if you make an API call with a nonsense value? or will your frontend code error when encountering them? who knows! I hope your tests are thorough
  • complexity: your code may have to deal with strange values that needn't exist
  • waste: glitchy calculations are by definition immediately invalidated, so they're pure waste - this can affect the UX with many updates or expensive calculations

Derived stores have glitches:

const firstname = writable('Barbara');
const lastname = writable('Liskov');
const fullname = derived([firstname, lastname], ([$firstname, $lastname]) => {
	const $fullname = $firstname + ' ' + $lastname;
	console.log('derived fullname', $fullname);
	return $fullname;
});
$fullname; // logs "Barbara Liskov"
$firstname = 'Alan'; // glitch - logs "Alan Liskov"
$lastname = 'Turing'; // logs "Alan Turing"

REPL

Signals are one reactivity system that can avoid glitches, depending on the implementation[1]. Svelte 5 and the proposed standard are glitch-free.

One important detail here is that signals do allow reading intermediate values. If you read fullname in between the two updates, you will indeed see the same value as the glitch. This makes sense, because it means it's consistent as described above with reactive statements. The key point is that unlike derived stores, signals do not see or calculate intermediate values by default - they can but it's under your control. Explicitly pulling an intermediate value is not a glitch.

Also, reactive statements are incapable of observing intermediate values, a limitation that can necessitate some painful restructuring in rare but valid cases.

Signals give us the best of both worlds, with the eager observability of derived stores, and the glitch-free efficient batching of reactive statements. They afford not only fine-grained performance, but also fine-grained control[2].

A complete example

This REPL compares Svelte 3 reactive statements, derived stores, and Svelte 5 signal-based runes. These three different reactive systems have subtle but important differences.

Here are the key lines of code being compared, rewritten to remove the logging noise needed for the demo:

Reactive statements:

$: ab = a + ' ' + b;

Derived stores:

const ab = derived([a, b], ([$a, $b]) => $a + ' ' + $b);

Signals:

const ab = $derived(a + ' ' + b);

Things to notice in the REPL:

  • the final rendered output in the DOM is what we expect, leading many people to be unaware of the lurking problems (e.g. clicking "swap uppercase and lowercase" never displays A b or a B)
  • reactive statements are undefined during initialization - this is inconsistent
  • reactive statements have stale values when read synchronously after writes to their dependencies - click the swap button and see how the after swap values are the same as before swap for reactive statements, unlike the other two examples - this is inconsistent
  • derived stores calculate and notify glitchy values like A b, a B, and they also wastefully recompute immediately after each dependency change, even when more changes to other dependencies are synchronously incoming
  • the signals-based $derived rune has neither of these problems

With both the reactive statements and derived stores, your code receives unexpected values in some cases, whereas signals always give you the latest value and they update predictably. Though subtle, these details can sometimes be critically important.

Signals sidestep glitches while providing a consistent and more optimal reactivity model.

From easy to simple

Engineering is all about tradeoffs, and as Rich Harris said in this interview, Svelte before version 5 was overly focused on the first 5 minutes of the developer experience. Svelte 3's apparent simplicity is full of subtle complexity. The cost of easy was unexpectedly high, and part of that cost was paid in simplicity for nontrivial usage. This post covers only a small fraction of Svelte 3's complexities. If you're curious about the details you could start with these issues.

Svelte 3 has two different reactive systems - component-local reactivity (including reactive statements) and stores (including derived) - each with its own problems, and where the former system is available only inside .svelte files. This creates two worlds where interactions between them can be surprising, reasoning through the details is challenging, and refactoring code between them is tedious and error-prone.

In 2019 I rationalized this by thinking I could select the better system for the problem at hand, but I eventually felt that neither system is great, and the combination is unwelcomed complexity. The Svelte team concluded similar and did some serious engineering to isolate the unavoidable complexities behind friendly APIs.

Svelte 5 has universal reactivity across both JS/TS modules and Svelte files instead of splitting the world in two, and runes, which leverage Svelte's compiler architecture, further sweeten signals syntax. I could continue listing the wins in Svelte 5's design, but I think the most important points are the improved reactivity model, universality, and composability (maybe a future post). The UX improves too with better performance, not to mention the likelihood of more productive and happier developers.

I've experienced many of the pain points of the previous design, and now having spent months with the pre-release of Svelte 5, I can confidently say the relief it brings is significant. My code is easier to read and write, runes guide me to better decision making, and its performance characteristics are pushing the state of the art. I'm looking forward to many more years of using Svelte with solid productivity and good vibes.

Footnotes

[1]

Not all signals implementations are glitch-free, as I understand the term. (in hindsight I think I'm misinterpreting the Wikipedia glitch description, confusing glitches with something related to transactions) For example my first video describes glitchy values that are possible in the store-compatible Preact Signals library (starting at this timestamp). Preact Signals provides batch to create explicit transactions that do not glitch. Svelte 5 instead batches automatically, with tradeoffs like effects being deferred instead of eager. I generally prefer Svelte 5's tradeoffs.

[2]

The deferred effects of Svelte 5's signals have good ergonomics, but like reactive statements, they cannot observe intermediate values. This makes Svelte 5's control less fine-grained than what's possible, although one can imagine extending its API to opt into eager effects (which are sometimes glitchy and wasteful). The TC39 signals proposal has no opinion on effects because it's unclear what's best - and maybe there's no perfect answer. I'd love to learn more about this if you have resources to share.


Comments