I want to share my understanding of the current reactivity approaches and landscape. These are my points of view and opinions; some may be hot-takes, so buckle up. (I am not saying I am right, but this is how I view the world.)
I think it is through sharing one's point of view that we come to a common understanding of things in the industry, and I hope that some of these hard-earned insights, which took me years, may be useful for others and may complement missing pieces in their understanding. Also, I value feedback as I am sure even after all these years, my understanding is more of a delicate woven web than a steel cage.
I think there are fundamentally three approaches to reactivity that we have seen in the industry thus far:
- Value-based; that is, dirty-checking: (Angular, React, Svelte)
- Observable-based: (Angular with RxJS, Svelte)
- Signal-based: (Angular with signals, Qwik, React with MobX, Solid, Vue)
Value-based systems rely on storing the state in a local (non-observable) reference as a simple value.
When I say "observable," I do not mean Observables such as RxJS. I mean the common use of the word observable as in to know when it has changed. "Non-observable" means there is no way of knowing the specific instance in time when the value has changed.
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<h1>Counter: {{ count }}</h1>
<button (click)="increment()">Increment</button>
`,
})
export class CounterComponent {
count: number = 0;
increment() {
this.count++;
}
}
<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<div>
<h1>Counter: {count}</h1>
<button on:click={increment}>Increment</button>
</div>
In each case above, the state is stored as a value either in a variable, closed over a variable, or a property. But the point is it is just a non-observable value stored in JavaScript in a way that does not allow the framework to know (observe) when the value changes.
Because the value is stored in a way that does not allow the framework to observe the mutation, each framework needs a way to detect when these values change and mark the component(s) as dirty.
Once marked dirty, the component is re-run so that the framework can re-read/re-create the values and therefore detect which parts have changed and reflect the changes to the DOM.
🌶️ HOT TAKE: Dirty-checking is the only strategy that can be employed with value-based systems. Compare the last known value with the current value. This is the way.
How do you know when to run the dirty-check algorithm?
- Angular(before signals) => implicit reliance on
zone.js
to detect when the state may have changed. (Because it relies on implicit detection throughzone.js
it runs change-detection more often than it is strictly necessary.) - React => explicit reliance on the developer to call
setState()
. - Svelte => compiler guards/invalidation around state assignments (essentially auto-generating
setState()
calls).
Observables are values over time. Observables allow the framework to know the specific instance-in-time when the value changes because pushing a new value into observable requires a specific API that acts as a guard.
Observables are the obvious way to solve the fine-grain reactivity problem. Still, they don't have the best DX because Observables require an explicit call to .subscribe()
and a corresponding call to .unsubscribe()
. Observables also don't guarantee synchronous glitch-free delivery, creating problems for UI, which tends to favor synchronous (transactional) updates.
import { Component } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-counter',
template: `
<h1>Counter: {{ count$ | async }}</h1>
<button (click)="increment()">Increment</button>
`,
})
export class CounterComponent {
private countSubject = new BehaviorSubject<number>(0);
count$: Observable<number> = this.countSubject.asObservable();
increment() {
this.countSubject.next(this.countSubject.value + 1);
}
}
<script>
import { writable } from 'svelte/store';
const count = writable(0);
function increment() {
// Update the count by incrementing it
count.update(n => n + 1);
}
</script>
<div>
<h1>Counter: {$count}</h1>
<button on:click={increment}>Increment</button>
</div>
Svelte: Interestingly, it has two reactivity systems with different mental models and syntaxes. This is because the value-based model only works in.svelte
files, so moving code outside the.svelte
file requires some other reactivity primitive (Stores).
I believe each framework should have a single reactivity model that can handle all the use cases rather than a combination of different reactivity systems based on the use case.
Signals are like synchronous cousins of Observables without the subscribe/unsubscribe. I believe this is a major DX improvement and that’s why I also believe signals are the future.
The implementation of Signals is not obvious, which is why it took so long for the industry to get here. Signals need to be tightly coupled to the underlying framework to get the best DX and performance.
For the best outcome, framework rendering and observable updates need to be coordinated. That is why in my opinion, framework-agnostic signal libraries are unlikely to become status-quo.
export const Counter = component$(() => {
const count = useSignal(123);
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
);
});
export const Counter = (() => {
const [count, setCount] = createSignal(123);
return (
<button onClick={() => setCount(count() + 1)}>
{count()}
</button>
);
});
<template>
<section>
<h1>Count: {{ count }}</h1>
<button @click="incrementCount">+1</button>
</section>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
function incrementCount() {
count.value++;
}
</script>
Angular is working on signals, but they still need signal and template integration, so I have yet to include an Angular example. But I love the direction they are moving in — and it is the right one, in my opinion.
Even though I have my favorites, all approaches have pros and cons and, therefore, tradeoffs. So let's start with the pros:
“Fall of the reactivity cliff” - All reactivity models have rules that must be followed to update the UI when the state is changed. If those rules are not followed, then the UI fails to update. I refer to this as “falling of the reactivity cliff.” A small change to how you do things may result in the UI not being updated. Different reactivity models have different foot guns for falling off, which should be considered.
Value-based:
- It just works: Value-based systems "just work.” You don't have to wrap objects in special containers, they are easy to pass around, and they are easy to type (TypeScript).
- Hard to fall off: A corollary of just-works is that it is hard to fall off the reactivity cliff. You are free to write code in many different ways with expected results.
- Easy to explain mental model: consequences of the above are easy to explain.
Observable-based:
- Values over time is a compelling concept that can express very complex scenarios and is a good fit for the browser event system, which is events over time (but not for rendering UIs that often need to re-render with the same state.)
Signal-based:
- Always performant/no need to optimize: Performance out of the box.
- A very good fit for the UI transactional/synchronous update model.
Value-based:
- Performance foot-guns: Performance slows down over time and requires "optimization refactoring”, which creates "performance experts.” For this reason, these frameworks provide "optimization"/"escape hatch" APIs to make things faster.
- Once you start optimizing, one can fall off the "reactivity-cliff" (UI stops updating, so in that sense, it is the same as signals)
Because of the clever compiler, the Svelte degradation is extremely shallow, so it is probably fine in practice.
Observable-based:
- Observables are not a good fit for UI. The UI represents a value to be shown now, not values over time. For this reason, we have
BehaviorSubjects
, which allow for synchronous reads and writes. - Observables are complicated. They are hard to explain. There are dedicated courses on observables alone.
- The explicit
subscribe()
is not a good DX as it requires subscribing (allocating callbacks) for each binding location. - The need for
unsubscribe()
is a memory-leak footgun.
NOTE: Many frameworks can automatically createsubscribe()
/unsubscribe()
calls for simple cases, but more complex cases often require that the developer take on the responsibility of subscription.
Signal-based:
- More rules than "value-based". Not following rules results in a broken reactivity (falling off the reactivity cliff).
Observables are way too complicated and not a good fit for UI (since only BehaviorSubject
observables really work with UI.) So I am going to spend little time on it.
I think the trade-off between value-based and signal-based is easy to get started ⇒ performance problems later vs. slightly more rules (more knowledge) to get started ⇒ but no need for optimization later.
With a value-based system, the performance is death by a thousand cuts. There is no one change that breaks the app. It is just “one day it is too slow.” The "too slow" issue is hard to define as developers tend to have fast machines, and mobile users complain first. Once you want to do optimization, there are no "obvious" things to fix.
Instead, it is a long and slow burn-down of accumulated debt over many man-years. Additionally, the "optimization" API now introduces footguns where you can fall off the reactivity cliff (updates stop propagating).
With a signal-based system, the initial understanding needs to be slightly higher, and it is possible to fall off the reactivity cliff. However, falling off the cliff is immediate, obvious, and straightforward to fix.
If you make a reactivity mistake with signals, the app breaks. It is obvious! The fix is obvious, too. You did not follow one of the reactivity rules, you learned your lesson, and you probably will not make that mistake again. Fast learning cycle.
Once you start optimizing the value-based systems, you are in the land of the signal-based world, where you can fall off reactivity in the exact same way as with signals. Essentially, the value-based "optimization" API is “signals with subpar DX.”
So, the question in front of you is, do you want to fail fast or fail slowly? I prefer the fail-fast mode.
There is the second reason why I like signals. Signals open up a possibility for cool tools which allow you to visualize the reactivity graph of the system and debug it.
I think, even though Signals require slightly higher investment, they will prevail over time. This is why I say: "I don't know which framework will become popular (I have my favorite), but I do think your next framework will be signal-based."
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.