Reactivity Models Explained
Foreword
It’s been (already) 10 years since I started developing applications and websites, but the JavaScript ecosystem has never been more exciting than it is today!
In 2022, the community was captivated by the concept of "Signal" to the point where most JavaScript frameworks integrated them into their own engine. I’m thinking about Preact, which has offered reactive variables decoupled from the component lifecycle since September 2022; or more recently Angular, which implemented Signals experimentally in May 2023, then officially starting from version 18. Other JavaScript libraries have also chosen to rethink their approach...
Between 2023 and until now, I’ve consistently used Signals across various projects. Their simplicity of implementation and usage has fully convinced me, to the extent that I’ve shared their benefits with my professional network during technical workshops, training sessions, and conferences.
But more recently, I started asking myself if this concept was truly "revolutionary" / if there are alternatives to Signals? So, I delved deeper into this reflection and discovered different approaches to reactive systems.
This post is an overview of different reactivity models, along with my understanding of how they work.
NB: At this point, you’ve probably guessed it, I won’t be discussing about Java’s "Reactive Streams"; otherwise, I’d have titled this post "WTF Is Backpressure!?" 😉
Theory
When we talk about reactivity models, we're (first and foremost) talking about "reactive programming", but especially about "reactivity".
The reactive programming is a development paradigm that allows to automatically propagate the change of a data source to consumers.
So, we can define the reactivity as the ability to update dependencies in real time, depending on the change of data.
NB: In short, when a user fills and/or submits a form, we must react to these changes, display a loading component, or anything else that specifies that something is happening... Another example, when receiving data asynchronously, we must react by displaying all or part of this data, executing a new action, etc.
In this context, reactive libraries provide variables that automatically update and propagate efficiently, making it easier to write simple and optimized code.
To be efficient, these systems must re-compute/re-evaluate these variables if, and only if, their values have changed! In the same way, to ensure that the broadcasted data remains consistent and up-to-date, the system must avoid displaying any intermediate state (especially during the computation of state changes).
NB: The state refers to the data/values used throughout the lifetime of a program/application.
Alright, but then… What exactly are these "reactivity models"?
PUSH, a.k.a "Eager" Reactivity
The first reactivity model is called "PUSH" (or "eager" reactivity). This system is based on the following principles:
- Initialization of data sources (as known as "Observables")
- Components/Functions subscribe to these data sources (these are the consumers)
- When a value changes, data is immediately propagated to the consumers (as known as "Observers")
As you might have guessed, the "PUSH" model relies on the "Observable/Observer" design pattern.
1st Use Case : Initial State and State Change
Let’s consider the following initial state,
let a = { firstName: "John", lastName: "Doe" };
const b = a.firstName;
const c = a.lastName;
const d = `${b} ${c}`;
Using a reactive library (such as RxJS), this initial state would look more like this:
let a = observable.of({ firstName: "John", lastName: "Doe" });
const b = a.pipe(map((a) => a.firstName));
const c = a.pipe(map((a) => a.lastName));
const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
NB: For the sake of this post, all code snippets should be considered as "pseudo-code."
Now, let’s assume that a consumer (a component, for example) wants to log the value of state D
whenever this data source is updated,
d.subscribe((value) => console.log(value));
Our component would subscribe to the data stream; it still needs to trigger a change,
a.next({ firstName: "Jane", lastName: "Doe" });
From there, the "PUSH" system detects the change and automatically broadcasts it to the consumers. Based on the initial state above, here’s a description of the operations that might occur:
- State change occurs in data source
A
! - The value of
A
is propagated toB
(computation of data sourceB
); - Then, the value of
B
is propagated toD
(computation of data sourceD
); - The value of
A
is propagated toC
(computation of data sourceC
); - Finally, the value of
C
is propagated toD
(re-computation of data sourceD
);
One of the challenges of this system lies in the order of computation. Indeed, based on our use case, you’ll notice that D
might be evaluated twice: a first time with the value of C
in its previous state; and a second time with the value of C
up to date! In this kind of reactivity model, this challenge is called the "Diamond Problem" ♦️.
2nd Use Case : Next Iteration
Now, let’s assume the state relies on two main data sources,
let a = observable.of({ firstName: "Jane", lastName: "Doe" });
let e = observable.of("");
const b = a.pipe(map((a) => a.firstName));
const c = merge(a, e).pipe(reduce((a, e) => `${e} ${a.lastName}`));
const d = merge(b, c).pipe(reduce((b, c) => `${b} ${c}`));
d.subscribe((value) => console.log(value));
e.next("Phoenix");
When updating E
, the system will re-compute the entire state, which allows it to preserve a single source of truth by overwriting the previous state.
- State change occurs in data source
E
! - The value of
A
is propagated toB
(computation of data sourceB
); - Then, the value of
B
is propagated toD
(computation of data sourceD
); - The value of
A
is propagated toC
(computation of data sourceC
); - The value of
E
is propagated toC
(re-computation of data sourceC
);. - Finally, the value of
C
is propagated toD
(re-computation of data sourceD
);
Once again, the "Diamond Problem" occurs... This time on the data source C
which is potentially evaluated 2 times, and always on D
.
Diamond Problem
The "Diamond Problem" isn't a new challenge in the "eager" reactivity model. Some computation algorithms (especially those used by MobX) can tag the "nodes of the reactive dependency tree" to level out state computation. With this approach, the system would first evaluate the "root" data sources (A
and E
in our example), then B
and C
, and finally D
. Changing the order of state computations helps to fix this kind of problem.
PULL, a.k.a "Lazy" Reactivity
The second reactivity model is called "PULL". Unlike the "PUSH" model, it is based on the following principles:
- Declaration of reactive variables
- The system defers state computation
- Derived state is computed based on its dependencies
- The system avoids excessive updates
It’s this last rule that is most important to remember: unlike the previous system, this last one defers state computation to avoid multiple evaluations of the same data source.
1st Use Case : Initial State and State Change
Let's keep the previous initial state...
In this kind of system, the initial state syntax would be in the following form:
let [a, setA] = state({ firstName: "John", lastName: "Doe" });
const b = computed(() => a.firstName, [a]);
const c = computed(() => a.lastName, [a]);
const d = computed(() => `${b} ${c}`, [b, c]);
NB: React enthusiasts will likely recognize this syntax 😎
Declaring a reactive variable gives "birth" to a tuple: immutable variable on one side; update function of this variable on the other. The remaining statements (B
, C
and D
in our case) are considered as derived states since they "listen" to their respective dependencies.
setA({ firstName: "Jane", lastName: "Doe" });
The defining characteristic of a "lazy" system is that it doesn't propagate changes immediately, but only when explicitly requested.
effect(() => console.log(d), [d]);
In a "PULL" model, using an effect()
(from a component) to log the value of a reactive variable (specified as a dependency) triggers the computation of the state change:
-
D
will check if its dependencies (B
andC
) have been updated; -
B
will check if its dependency (A
) has been updated; -
A
will propagate its value toB
(computing the value ofB
); -
C
will check if its dependency (A
) has been updated; -
A
will propagate its value toC
(computing the value ofC
) -
B
andC
will propagate their respective value toD
(computing the value ofD
);
An optimization of this system is possible when querying dependencies. Indeed, in the scenario above, A
is queried twice to determine whether it has been updated. However, the first query could be enough to define if the state has changed. C
wouldn't need to perform this action... Instead, A
could only broadcast its value.
2nd Use Case : Next Iteration
Let's complicate the state somewhat by adding a second reactive variable "root",
let [a, setA] = state({ firstName: "Jane", lastName: "Doe" });
let [e, setE] = state("");
const b = computed(() => a.firstName, [a]);
const c = computed(() => `${e} ${a.lastName}`, [a, e]);
const d = computed(() => `${b} ${c}`, [b, c]);
effect(() => console.log(d), [d]);
setE("Phoenix");
One more time, the system defers state computation until it is explicitly requested. Using the same effect
as before, updating a new reactive variable will trigger the following steps:
-
D
will check if its dependencies (B
andC
) have been updated ; -
B
will check if its dependency (A
) has been updated ; -
C
will check if its dependencies (A
andE
) have been updated ; -
E
will propagate its value toC
, andC
will fetch the value ofA
via memoization (computing the value ofC
) ; -
C
will propagate its value toD
, andD
will fetch the value ofB
via memoization (computing the value ofD
) ;
Since the value of A
hasn't changed, recomputing this variable is unnecessary (same thing applies to the value of B
). In such cases, the use of memoization algorithms enhances performance during state computation.
PUSH-PULL, a.k.a "Fine-Grained" Reactivity
The last reactivity model is the "PUSH-PULL" system. The term "PUSH" reflects the immediate propagation of change notifications, while "PULL" refers to fetching the state values on demand. This approach is closely related to what is called "fine-grained" reactivity, which adheres to the following principles:
- Declaration of reactive variables (we're talking about reactive primitives)
- Dependencies are tracked at an atomic level
- Change propagation is highly targeted
Note that this kind of reactivity isn't exclusive to the "PUSH-PULL" model. Fine-grained reactivity refers to the precise tracking of system dependencies. So, there are PUSH and PULL reactivity models which also work in this way (I'm thinking about Jotai or Recoil.
1st Use Case : Initial State and State Change
Still based on the previous initial state... The declaration of an initial state in a "fine-grained" reactivity system would look like this:
let a = signal({ firstName: "John", lastName: "Doe" });
const b = computed(() => a.value.firstName);
const c = computed(() => a.value.lastName);
const d = computed(() => `${b.value} ${c.value}`);
NB: The use of the signal
keyword isn't just anecdotal here 😉
In terms of syntax, it’s very similar to the "PUSH" model, but there is one notable and important difference: dependencies! In a "fine-grained" reactivity system, it’s not necessary to explicitly declare the dependencies required to compute a derived state, as these states implicitly track the variables they use. In our case, B
and C
will automatically track changes to the value of A
, and D
will track changes to both B
and C
.
a.value = { firstName: "Jane", lastName: "Doe" };
In such a system, updating a reactive variable is more efficient than in a basic "PUSH" model because the change is automatically propagated to the derived variables that depend on it (only as a notification, not the value itself).
effect(() => console.log(d.value));
Then, on demand (let's take the logger example), the use of D
within the system will fetch the values of the associated root states (in our case A
), compute the values of the derived states (B
and C
), and finally evaluate D
. Isn't it an intuitive mode of operation?
2nd Use Case : Next Iteration
Let's consider the following state,
let a = signal({ firstName: "Jane", lastName: "Doe" });
let e = signal("");
const b = computed(() => a.value.firstName);
const c = computed(() => `${e.value} ${e.value.lastName}`);
const d = computed(() => `${b.value} ${c.value}`);
effect(() => console.log(d.value));
e.value = "Phoenix";
Once again, the "fine-grained" aspect of the PUSH-PULL system allows for automatic tracking of each state. So, the derived state C
now tracks root states A
and E
. Updating the variable E
will trigger the following actions:
- State change of the reactive primitive
E
! - Targeted change notification (
E
toD
viaC
); -
E
will propagate its value toC
, andC
will retrieve the value ofA
via memoization (computing the value ofC
); -
C
will propagate its value toD
, andD
will retrieve the value ofB
via memoization (computing the value ofD
);
This is that prior association of reactive dependencies with each other that makes this model so efficient!
Indeed, in a classic "PULL" system (such as React's Virtual DOM, for example), when updating a reactive state from a component, the framework will be notified of the change (triggering a "diffing" phase). Then, on demand (and deferred), the framework will compute the changes by traversing the reactive dependency tree; every time a variable is updated! This "discovery" of the state of dependencies has a significant cost...
With a "fine-grained" reactivity system (like Signals), the update of reactive variables/primitives automatically notifies any derived state linked to them of the change. Therefore, there’s no need to (re)discover the associated dependencies; the state propagation is targeted!
Conclusion(.value)
In 2024, most web frameworks have chosen to rethink how they work, particularly in terms of their reactivity model. This shift has made them generally more efficient and competitive. Others choose to be (still) hybrid (I'm thinking about Vue here), which makes them more flexible in many situations.
Finally, whatever the model chosen, in my opinion, a (good) reactive system is built upon a few main rules:
- The system prevents inconsistent derived states;
- The use of a state within the system results in a reactive derived state;
- The system minimizes excessive work ;
- And, "for a given initial state, no matter the path the state follows, the system's final result will always be the same!"
This last point, which can be interpreted as a fundamental principle of declarative programming, is how I see a (good) reactive system as needing to be deterministic! This is that "determinism" that makes a reactive model reliable, predictable, and easy to use in technical projects at scale, regardless of the complexity of the algorithm.
Top comments (0)