Reactivity Models Explained
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!?" ?
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"?
The first reactivity model is called "PUSH" (or "eager" reactivity). This system is based on the following principles:
As you might have guessed, the "PUSH" model relies on the "Observable/Observer" design pattern.
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:
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" ♦️.
Now, let’s assume the state relies on two main data sources,
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
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.
Once again, the "Diamond Problem" occurs... This time on the data source C which is potentially evaluated 2 times, and always on D.
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.
The second reactivity model is called "PULL". Unlike the "PUSH" model, it is based on the following principles:
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.
Let's keep the previous initial state...
In this kind of system, the initial state syntax would be in the following form:
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: 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.
d.subscribe((value) => console.log(value));
The defining characteristic of a "lazy" system is that it doesn't propagate changes immediately, but only when explicitly requested.
let a = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
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:
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.
Let's complicate the state somewhat by adding a second reactive variable "root",
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}`));
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:
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.
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:
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.
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 = { firstName: "John", lastName: "Doe" }; const b = a.firstName; const c = a.lastName; const d = `${b} ${c}`;
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.
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}`));
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).
d.subscribe((value) => console.log(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?
Let's consider the following state,
a.next({ firstName: "Jane", lastName: "Doe" });
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:
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!
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:
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.
The above is the detailed content of WTF Is Reactivity !?. For more information, please follow other related articles on the PHP Chinese website!