DEV Community

loading...
Cover image for A Hands-on Introduction to Fine-Grained Reactivity

A Hands-on Introduction to Fine-Grained Reactivity

ryansolid profile image Ryan Carniato Updated on ・8 min read

Reactive Programming has existed for decades but it seems to come in and out of fashion. In JavaScript frontends, it has been on the upswing again for the last couple of years. It transcends frameworks and is a useful subject for any developer to be familiar with.

However, it isn't always that easy. For starters, there are different types of reactivity. The terms and naming are often overloaded with the same word meaning different things to different people.

Secondly, it sometimes looks like magic. It isn't, but it's harder not to get distracted by the "how" before understanding the "what". This makes it a challenge to teach by practical example and becomes a careful balance to prevent going too theoretical.

This article is not going to focus on the "how". I will attempt to provide the most gentle introduction into the Fine-grained reactivity the approach used by libraries like MobX, Vue, Svelte, Knockout, and Solid.

Note: This may be different than the reactivity you might be familiar with streams like RxJS. They are related and there are similarities but they are not quite the same thing.

While this an article aimed at people brand new to fine-grained reactivity or reactivity in general, it is still an intermediate level topic that assumes knowledge of JavaScript and familiarity with some introductory Computer Science topics. I will try my best to explain things in detail but feel free to leave questions in the comments.

I will be posting code snippets and examples in Codesandbox. I will be using my library Solid to power these examples and syntax in this article will use its syntax. But it is more or less the same in all libraries. Follow the links to play with these examples in a fully interactive environment.


The Players

Fine-grained reactivity is built from a network of primitives. By primitives, I am referring to simple constructs like Promises rather than JavaScript's primitive values like strings or numbers.

Each act as nodes in a graph. You can think of it as an idealized electric circuit. Any change applies to all nodes at the same time. The problem being solved is synchronization at a single point in time. This is a problem space we often work in when building user interfaces.

Let's get started by learning about the different types of primitives.

Signals

Signals are the most primary part of a reactive system. They consist of a getter, setter, and a value. Although often referred to as Signals in academic papers, they also have been called Observables, Atoms, Subjects, or Refs.

const [count, setCount] = createSignal(0);

// read a value
console.log(count()); // 0

// set a value
setCount(5);
console.log(count()); //5
Enter fullscreen mode Exit fullscreen mode

Of course, that alone isn't very interesting. These are more or less just values that can store anything. The important detail is that both the get and set can run arbitrary code. This will be important to propagate updates.

Functions are the primary way to do this but you may have seen it done via object getters or proxies:

// Vue
const count = ref(0)
// read a value
console.log(count.value); // 0

// set a value
count.value = 5;
Enter fullscreen mode Exit fullscreen mode

Or hidden behind a compiler:

// Svelte
let count = 0;
// read a value
console.log(count); // 0

// set a value
count = 5;
Enter fullscreen mode Exit fullscreen mode

At their heart Signals are event emitters. But the key difference is the way subscriptions are managed.

Reactions

Signals alone are not very interesting without their partner in crime, Reactions. Reactions, also called Effects, Autoruns, Watches, or Computeds, observe our Signals and re-run them every time their value updates.

These are wrapped function expressions that run initially, and whenever our signals update.

console.log("1. Create Signal");
const [count, setCount] = createSignal(0);

console.log("2. Create Reaction");
createEffect(() => console.log("The count is", count()));

console.log("3. Set count to 5");
setCount(5);

console.log("4. Set count to 10");
setCount(10);
Enter fullscreen mode Exit fullscreen mode

This looks a bit like magic at first, but it is the reason for our Signals to need getters. Whenever the signal is executed the wrapping function detects it and automatically subscribes to it. I will explain more about this behavior as we continue.

The important thing is these Signals can carry any sort of data and the reactions can do anything with it. In the CodeSandbox examples I created a custom log function to append DOM elements to the page. We can coordinate any update with these.

Secondly, the updates happen synchronously. Before we can log the next instruction the Reaction has already run.

And that's it. We have all the pieces we need for fine-grained reactivity. The Signal and the Reaction. The observed and the observer. In fact, you create most behavior with just these two. However, there is one other core primitive we need to talk about.

Derivations

More often than not we need to represent our data in different ways and use the same Signals in multiple Reactions. We can write this in our Reactions, or even extract a helper.

console.log("1. Create Signals");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const fullName = () => {
  console.log("Creating/Updating fullName");
  return `${firstName()} ${lastName()}`
};

console.log("2. Create Reactions");
createEffect(() => console.log("My name is", fullName()));
createEffect(() => console.log("Your name is not", fullName()));

console.log("3. Set new firstName");
setFirstName("Jacob");
Enter fullscreen mode Exit fullscreen mode

Note: In this example fullName is a function. This is because in order for the Signals to be read underneath the Effect we need to defer executing it until the Effect is running. If it were simply a value there would be no opportunity to track or for the Effect to re-run.

But sometimes the computational cost of our derived value is expensive and we don't want to redo the work. For that reason, we have a 3rd basic primitive that acts similar to function memoization to store intermediate computations as their own signal. These are known as Derivations but are also called Memos, Computeds, Pure Computeds.

Compare what happens when we make fullName a Derivation.

console.log("1. Create Signals");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");

console.log("2. Create Derivation");
const fullName = createMemo(() => {
  console.log("Creating/Updating fullName");
  return `${firstName()} ${lastName()}`
});

console.log("3. Create Reactions");
createEffect(() => console.log("My name is", fullName()));
createEffect(() => console.log("Your name is not", fullName()));

console.log("4. Set new firstName");
setFirstName("Jacob");
Enter fullscreen mode Exit fullscreen mode

This time fullName calculates its value immediately on creation and then does not re-run its expression when read by the Reactions. When we update its source Signal it does re-run again, but only once as that change propagates to the Reactions.

While calculating a full name is hardly an expensive computation we can see how Derivations can save us work by caching the value in an independently executed expression, that is trackable itself.

More so, as they are derived they are guaranteed to be in sync. At any point, we can determine their dependencies and evaluate whether they could be stale. Using Reactions to write to other Signals might seem equivalent but cannot bring that guarantee. Those Reactions are not an explicit dependency of the Signal (as Signals have no dependencies). We will look more at the concept of dependencies in the next section.

Note: Some libraries lazy evaluate Derivations as they only need to be calculated upon read and it allows for aggressive disposal of Derivations that are not currently being read. There are tradeoffs between these approaches that go beyond the scope of this article.


Reactive Lifecycle

Alt Text

Fine-Grained reactivity maintains the connections between many reactive nodes. At any given change parts of the graph re-evaluate and can create and remove connections.

Note: Precompiled libraries like Svelte or Marko don't use the same runtime tracking technique and instead statically analyze dependencies. In so they have less control over when reactive expressions re-run so they may over-execute but there is less overhead for management of subscriptions.

Consider when a condition changes what data you use to derive a value:

console.log("1. Create");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const [showFullName, setShowFullName] = createSignal(true);

const displayName = createMemo(() => {
  if (!showFullName()) return firstName();
  return `${firstName()} ${lastName()}`
});

createEffect(() => console.log("My name is", displayName()));

console.log("2. Set showFullName: false ");
setShowFullName(false);

console.log("3. Change lastName");
setLastName("Legend");

console.log("4. Set showFullName: true");
setShowFullName(true);
Enter fullscreen mode Exit fullscreen mode

The thing to notice is that when we change the lastName in step 3, we do not get a new log. This is because every time we re-rerun a reactive expression we rebuild its dependencies. Simply, at the time we change the lastName no one is listening to it.

The value does change, as we observe when we set showFullName back to true. However, nothing is notified. This is a safe interaction since in order for lastName to become tracked again showFullName must change and that is tracked.

Dependencies are the signals that a reactive expression reads to generate its value. In turn, these signals hold the subscription of many reactive expressions. When they update they notify their subscribers who depend on them.

We construct these subscriptions/dependencies on each execution. And release them each time a reactive expression is re-run or when they are finally released. You can see that timing using an onCleanup helper:

console.log("1. Create");
const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Smith");
const [showFullName, setShowFullName] = createSignal(true);

const displayName = createMemo(() => {
  console.log("### executing displayName");
  onCleanup(() =>
    console.log("### releasing displayName dependencies")
  );
  if (!showFullName()) return firstName();
  return `${firstName()} ${lastName()}`
});

createEffect(() => console.log("My name is", displayName()));

console.log("2. Set showFullName: false ");
setShowFullName(false);

console.log("3. Change lastName");
setLastName("Legend");

console.log("4. Set showFullName: true");
setShowFullName(true);
Enter fullscreen mode Exit fullscreen mode


Synchronous Execution

Fine-grained reactive systems execute their changes synchronously and immediately. They aim to be glitch-free in that it is never possible to observe an inconsistent state. This leads to predictability since in any given change code only runs once.

Inconsistent state can lead to unintended behavior when we can't trust what we observe to make decisions and perform operations.

The easiest way to demonstrate how this works is to apply 2 changes simultaneously that feed into a Derivation that runs a Reaction. We will use a batch helper to demonstrate. batch wraps the update in a transaction that only applies changes when it finishes executing the expression.

console.log("1. Create");
const [a, setA] = createSignal(1);
const [b, setB] = createSignal(2);
const c = createMemo(() => {
  console.log("### read c");
  return b() * 2;
});

createEffect(() => {
  console.log("### run reaction");
  console.log("The sum is", a() + c());
});

console.log("2. Apply changes");
batch(() => {
  setA(2);
  setB(3);
});
Enter fullscreen mode Exit fullscreen mode

In this example, the code runs top-down through creation like you'd expect. However, the batched update reverses the run/read logs.

When we update the value even though A and B are applied at the same time, we need to start somewhere so we run A's dependencies first. So the effect runs first, but detecting that C is stale we immediately run it on read and everything executes once and evaluates correctly.

Sure you can probably think of an approach to solve this static case in order, but remember dependencies can change on any run. Fined-grained reactive libraries use a hybrid push/pull approach to maintain consistency. They are not purely "push" like events/streams, nor purely "pull" like generators.


Conclusion

This article covered a lot. We introduced the core primitives and touched on the defining characteristics of fine-grained reactivity, including dependency resolution and synchronous execution.

If the topics don't seem completely clear yet, that's ok. Review the article and try messing with the examples. These were meant to demonstrate the ideas in the most minimal way. But this is really most of it. With a little practice, you too will be able to see how to model data in a granular way.


Further Reading:
Building a Reactive Library from Scratch
The fundamental principles behind MobX
SolidJS: Reactivity to Rendering

Discussion (14)

pic
Editor guide
Collapse
drsensor profile image
DrsEnsor

I've been wondering why people like representing a reactive state in [get,set]=data instead of S.data 🤔
Is it because React introduces it that way?

Collapse
ryansolid profile image
Ryan Carniato Author • Edited

Admittedly I was staring at this problem for years before I ever saw React Hooks, but I knew in 2 seconds tuples were what I always wanted.

In JavaScript we've had a few versions. There is the single getter/setter function:

const s = createSignal(0);
// read a value
console.log(s()); // 0

// set a value
s(5);
console.log(s()); //5
Enter fullscreen mode Exit fullscreen mode

This one is probably the most awkward as a certain point you end up with.. is it a signal? is it a function? is it writable? If it is should I? What if I pass it into a function directly to track, and that function later starts calling with arguments. I've experience all of this first hand. KnockoutJS popularized this approach but it becomes pretty ambiguous once you leave local scope. Maybe it's because we aren't in a typed language, but this is about the most error prone approach there is.

const s = createSignal(0);
// read a value
console.log(s.get()); // 0

// set a value
s.set(5);
console.log(s.get()); //5
Enter fullscreen mode Exit fullscreen mode

This is how it works in MobX, and Svelte 2.. and probably where I would have ended up if I had never seen React Hooks. This isn't bad but it's a little verbose. You could always split them off though:

const { get, set } = createSignal(0);
Enter fullscreen mode Exit fullscreen mode

But multiple signals you end up doing a lot of aliasing:

const { get: s, set: setS } = createSignal(0);
Enter fullscreen mode Exit fullscreen mode

Vue using a simple .value getter is a 3rd option but it also has verbosity and unlike the separate .get .set you can't just destucture it as it uses assignment semantics to set. Of course you could always wrap each part in a function:

const ref = createSignal(0);
const s = () => ref.value;
const set = v => ref.value = v;
Enter fullscreen mode Exit fullscreen mode

After looking at all these none of them were preferable. The thing with tuples are you can name them exactly as you want. They have explicit meaning. A read is always just a function. Even with proxies and what not or derived expressions if wrap it in a thunk it's a signal. Like fullName in the first Derivation example above.

In general it makes it easier to visually see and talk about in terms of composition and makes it very easy to maintain this same [get, set] signature. This makes it not only the best teaching API but also in my opinion the just the best API for this.

Consider this useReducer composition:

const useReducer = (reducer, state) => {
  const [getState, setState] = createSignal(state);
  const [getAction, dispatch] = createSignal();
  createEffect(() => {
    const action = getAction();
    if (!action) return;
    state = reducer(state, action);
    setState(state);
  });
  return [getState, dispatch];
};
Enter fullscreen mode Exit fullscreen mode

Look at how you wire the the signals together to create derived behavior. It's just clean. Other patterns are not nearly as much.

EDIT: I am aware this specific example implementable without using the Effect and having the the dispatch just call the state reducer directly. But that is sort of besides the point. I wanted to show how you can easily wire these Signals together.

Collapse
drsensor profile image
DrsEnsor

Do you know any framework/libs that use this signature

interface Signal<State> {
  () => State
  set(val: State): void
}
function createSignal<T>(s: T): Signal<T>
Enter fullscreen mode Exit fullscreen mode

which the usage is something like

const state = createSignal(0)
// read a value
console.log(state()) // 0

// set a value
state.set(5)
console.log(state()) // 5
Enter fullscreen mode Exit fullscreen mode
Thread Thread
ryansolid profile image
Ryan Carniato Author

Actually no. The single function and the separate get/set are by far the most common. The tuple that I use is super uncommon in JavaScript in reactive libraries except maybe some newer ones in React.

This was one of the ones I was super leaning towards the month before hooks came out. I was worried it was too clever using the function as the object to hang it off of. We used to use this pattern in KnockoutJS a reasonable amount of the time to add augmented properties. Generally, it was for attaching other observables and was called sub-observables. I found while teaching that to newcomers they found it weird.

What I liked was it was minimal. It only creates the 2 functions. Syntaxtually it kept the common case easy (get) and let you be explicit on sets. No weird doubling up, and about as minimal syntax. You would still name the variable as you like.

I'm not sure if I'd have landed on it over the MobX style but now looking back at it I would think I wouldn't have hesitated on that API if I hadn't seen hooks. I only came to respect the read/write split after I started using it. As I started playing with composition patterns and reading more academic papers.

Collapse
peerreynders profile image
peerreynders • Edited

Using Observer Pattern terminology the "signal" comes from the "subject".

By using "getState" the "observer" not only "gets the state" of the subject but also implicitly subscribes to updates to the subject's state. Similarly "setState" triggers the machinery necessary to update anybody who's interested in the updated state.

See Finding Fine-Grained Reactive Programming: How It Works

Collapse
zarabotaet profile image
Dima • Edited

Great article, Ryan! As always)🤓👏👍

Recently, I have used reactive like state manager effector.dev Do you heard anything about it? Your opinion would be extremely interesting!

Also, the author of effector creates a simple library npmjs.com/package/forest
around effector reactivity. It's not production-ready and for usage in personal projects, but it seemed to me that it has great potential...

It has a native async incremental rendering and well-directed dom API representation.

Collapse
ryansolid profile image
Ryan Carniato Author • Edited

I've seen Effector, but hadn't seen Forest before. Effector is based on FRP concepts very similar to RxJS but with store based focus. Which is cool because in that context we can limit operations generally to map transformations and remove a lot of the "What 50 operators do I need to learn to get started?" issues typical observable libraries have.

In so it is really great way to created a directed graph with minimal API. It doesn't use things like proxies or dependency tracking since it's explicit. It supports the Observable standard too so you can hook it up to any place that can handle observables quite easily (gotta love how easy that is in Svelte).

Forest looks like a VDOM library created with Effector in mind. Which is pretty cool. But I think this is where you will start to see the motivation for things that Effector chooses not to do. The biggest benefit of auto-tracking like you find in fine-grained is that dependencies are constructed where they lie. Meaning that you can construct graphs without explicitly wiring things up. This is really great when trying to write inline expressions in templates or conditional logic. I always had this issue when trying to work with RxJS as a core change manager in a framework and there have been some projects to look at ways to alleviate this. Effector has the same limitation.

The thing is that looking at the way React or Svelte or Vue update, Forest can tell a pretty compelling tale. You can get a similar effect by driving things off this less framework integrated system. I mean put what you want in this box though really as most state management solutions more or less have the ability to drive a simple VDOM implementation on their own. The complexity comes in local state so who needs it? Not to start the debate again on local vs global state there are benefits to both approaches, but I do think that state management libraries like Effector that can work in a distributed way can do a good enough impression that you could make something pretty effective and pretty usable like this.

But if I was betting on the future, this isn't the sort of approach I'd be looking at on the framework side. State management, for sure.. But as you know I'm big on fine-grained and the reason is the granularity of update performance. Sure React, Vue, Svelte, don't get to leverage it in a way that makes it any better than what Forest is doing, but we shouldn't cut ourselves short there. The potential of well executed fine-grained reactivity has a much higher ceiling.

It is possible to do this with systems like Effector but it would be very verbose to create all the necessary subscriptions at that level and manage the dynamic nature of them. You'd have to do a lot of explicit work. Things perhaps you could analyze with a compiler, but until we got to a point of perfect analysis, something like proxies ensures we can do this sort of thing with no extra syntax and ultimate dynamicism.

Obviously this is all my opinion and I'm biased in this regard. But the performance that I've created with Solid is repeatable. We're already seeing it in early prototypes of future version of Marko. I imagine when things get rolling we may see more libraries like Vue or Svelte look at this work. Or not. After all the approaches I am using for fine-grained rendering in Solid have been around since at least 2016(I was not the first to experiment here). But where I currently stand nothing else is coming close in terms of streamlining DX and performance. Which is why I believe more people should be learning about fine-grained reactivity.

Collapse
zarabotaet profile image
Dima

Thank you for such a comprehensive answer!

Collapse
omril321 profile image
Omri Lavi

That's awesome, thank you.
I like how you explained things without being opinionated about specific libraries.

Collapse
artydev profile image
artydev

Great Ryan,
Thank you

Collapse
ferhatavdic profile image
FerhatAvdic

The exact same patterns like hooks in react (useMemo, useCallback, useState, useEffect)

Collapse
ryansolid profile image
Ryan Carniato Author • Edited

It's similar looking but mechanically very different. I've talked about this previously in Exploring the State of Reactivity Patterns in 2020.

This is part of why I want to teach people about this. React's model is one where the component runs over and over. To make my demo would have been a very different environment. Now some libraries do use this reactivity inside React and Vue is basically like if you put the 2 together. But purely React Hooks is different. Compare this React code:

React useInterval

To this Solid code:
Solid useInterval

Both of these actually function identically but this illustrates the difference in the Reactive update model. React uses refs to retain references between executions and has to be aware of stale callbacks. In the reactive case we need to ensure that props.delay doesn't execute early so we wrap it in a function but otherwise the logic is very similar to how you would write this without a framework.

Collapse
ferhatavdic profile image
FerhatAvdic • Edited

Why didnt you wrap the inline count function within a useCallback hook?
Also using a state object withing the set method may cause an infinite loop if used within a use effect so you need to use the previous value.

Apply both and u get this:

const incrementCount = useCallback(()=>setCount((prev)=>prev+1),[])

And why not put the callback in the useEffect dependency array?

The function would then look like this:

function useInterval(callback,delay){
useEffect(()=>{
let id = setInterval(callback,delay)
return () => clearInterval(id)
},[delay, callback])
}

But youre still right about the refs. React needs to have the refs explicitly mentioned in the array of dependencies to keep track of changes.

I wonder if it is possible to use Solid within react and im curious if it would make things easier and better.

Edit:
Your approach is great. I actually learned something for future reference. You wrote the code in a way that the next dev who is using the useInterval function doesnt have to think about wrapping their callback function within a useCallback. Thanks!

Thread Thread
ryansolid profile image
Ryan Carniato Author • Edited

Those are fair questions. I took this example directly from Dan Abramov's blog: overreacted.io/making-setinterval-...

I think his motivation was to solve this without using the function form of setState since he could have done that at the beginning of the article and moved on. In any case if you haven't read it, I highly recommend it.

I did make a react-solid-state library but it is basically like MobX. Pre-hooks it felt kind of cool, post-hooks I sort of lost interest in it. I thought React introducing its own primitives was a gamechanger and sure enough things like Recoil started showing up. React can never really leverage the benefits here in terms of execution performance and the DX is not amazing with the need for wrappers etc. There is probably a smarter way to approach it now but as I said limited benefits. Solid fully embraces fine-grained reactivity in a way other libraries don't and I've continued to focus there.