DEV Community

Cover image for WTF Is Reactivity !?
Damien Chazoule
Damien Chazoule

Posted on

WTF Is Reactivity !?

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}`;
Enter fullscreen mode Exit fullscreen mode

ABCD Initial State

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}`));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Our component would subscribe to the data stream; it still needs to trigger a change,

a.next({ firstName: "Jane", lastName: "Doe" });
Enter fullscreen mode Exit fullscreen mode

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 to B (computation of data source B);
  • Then, the value of B is propagated to D (computation of data source D);
  • The value of A is propagated to C (computation of data source C);
  • Finally, the value of C is propagated to D (re-computation of data source D);

Push ABCD

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");
Enter fullscreen mode Exit fullscreen mode

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 to B (computation of data source B);
  • Then, the value of B is propagated to D (computation of data source D);
  • The value of A is propagated to C (computation of data source C);
  • The value of E is propagated to C (re-computation of data source C);.
  • Finally, the value of C is propagated to D (re-computation of data source D);

Push ABCDE

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.

Diamond Solution

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...

ABCD Initial State Again

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]);
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 and C) have been updated;
  • B will check if its dependency (A) has been updated;
  • A will propagate its value to B (computing the value of B);
  • C will check if its dependency (A) has been updated;
  • A will propagate its value to C (computing the value of C)
  • B and C will propagate their respective value to D (computing the value of D);

Pull ABCD

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");
Enter fullscreen mode Exit fullscreen mode

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 and C) have been updated ;
  • B will check if its dependency (A) has been updated ;
  • C will check if its dependencies (A and E) have been updated ;
  • E will propagate its value to C, and C will fetch the value of A via memoization (computing the value of C) ;
  • C will propagate its value to D, and D will fetch the value of B via memoization (computing the value of D) ;

Pull ABCDE

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}`);
Enter fullscreen mode Exit fullscreen mode

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" };
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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?

Push-Pull ABCD

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";
Enter fullscreen mode Exit fullscreen mode

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 to D via C);
  • E will propagate its value to C, and C will retrieve the value of A via memoization (computing the value of C);
  • C will propagate its value to D, and D will retrieve the value of B via memoization (computing the value of D);

Push-Pull ABCDE

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:

  1. The system prevents inconsistent derived states;
  2. The use of a state within the system results in a reactive derived state;
  3. The system minimizes excessive work ;
  4. 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)