Why Fine-Grained Reactive Programming Suddenly Matters
There's been a quiet upheaval in front-end development over the last year that has left many developers scratching their heads. Right when ES6 Classes had become the norm, prominent libraries started dropping them left and right. Just as the ecosystem felt stable, this curveball appeared seemingly out of thin air.
I'm referring to those functional Components with features called Hooks and Computeds. Specifically, I mean the Vue RFC that recently surfaced. Concepts like observable data and explicit dependency tracking. All those on_____ and use_____ functions that will inevitably flood your screens for the foreseeable future.
This isn't actually coming out of nowhere, even though the sudden surge in popularity is puzzling. This is a paradigm that was gaining momentum in front-end circles before React arrived, then went dormant for roughly five years. I happen to be one of those developers who stuck with it, insisting it was the superior approach for UI development in terms of both developer experience and performance. So who better to introduce you to your new reality?
The Rationale
You might be tempted to throw your laptop across the room. We front-end developers get deeply attached to our tools, waving banners for our favorites like "Virtual DOM forever" or "HOCs are life." But progress rarely moves in a straight line. What's old eventually becomes new again.
The short version is that after years of trying to solve component-related problems by creating more components, someone finally realized there were other composable patterns worth exploring. Classes and Mixins never quite solved the issues cleanly, and sometimes Components are simply too heavyweight for what you need. The React team kept running into these problems internally, I suspect. Once React publicly embraced this pattern, the same community that had earlier dismissed it had a collective moment of realization. Suddenly everyone was wondering why they'd been waiting.
The crucial insight is that this approach enables Declarative Data. Your views are no longer the only declarative part; your state and its derivations become declarative too. Instead of scattering your logic across what I call the 5 Stages of Grief (formerly known as Lifecycle Functions) and managing a messy chain of state conditionals, you invert the hierarchy and organize code around the journey of each individual data atom. Once your data is co-located, it also becomes abstractable. You can extract behaviors and apply them across any component you want.
React Hooks, interestingly, aren't a genuine fine-grained reactive system. That's why they come with "Hook Rules" and numerous caveats around closures and references that aren't truly necessary for the approach. But I'll use them alongside examples from other libraries to get you up to speed on the core ideas.
Core Concepts: Observables and Computeds
Every library in this space — MobX, Vue, Ember, KnockoutJS, React, Solid, Svelte, and others — builds its reactive system on two fundamental primitives. The names vary across libraries, but there are always two: observables and computeds.
Note: You may have encountered the term Observable in the context of Functional Reactive Programming (FRP) — RxJS, CycleJS, Bacon — where it refers to streams. What I'm discussing here as Fine-Grained Reactive Programming is related to but not identical to Synchronous Reactive Programming (SRP). These observables behave like discrete signals that settle into a stable state, similar to how a digital circuit works.
Observables
Working with Fine-Grained Reactive Programming feels a lot like building a spreadsheet. You have cells that hold raw data, and other cells whose values are computed from those data cells. Observables represent the data cells. From a mechanical perspective, they're a simple construct with both a getter and a setter. They must be capable of detecting both when their value is read and when it's written. The implementation might resemble:
// KnockoutJS
const x1 = observable(5);
console.log(x1()); // get value
x1(8); //set value
// Vue RFC
const x2 = value(5);
console.log(x2.value);
x2.value = 8;
// Solid
const [x3, setX3] = createSignal(5);
console.log(x3());
setX3(8);
// MobX
const x4 = observable({data: 5});
console.log(x4.data);
x4.data = 8;
// React (no actual getter but for comparison purposes)
const [x5, setX5] = useState(5);
console.log(x5);
setX5(8);
Some implementations use a single function that takes variable arguments, others use separate functions, and some leverage object getter/setters or ES6 proxies. The essential behavior is that the setter catches every change, while every read gets tracked during getter execution. When you access a value matters a great deal here, as I'll explain shortly.
Computeds
If observables are the data cells in your spreadsheet, computeds are the formula cells. They're aware of their dependencies and know when to re-run when those dependencies change. You'll typically encounter two kinds of computeds: Pure Computeds, meant for deriving values, and Effectful Computeds, designed for side effects.
Note: Side effects refer to modifying values that lie outside the scope of the specifically invoked procedure. Since such functions aren't self-contained, they don't guarantee identical output for identical input. This is why they're called impure.
Here's how Pure Computeds appear across a few libraries:
// KnockoutJS
const c1 = pureComputed(() => x1() * 2);
console.log(c1()); // get value
// Vue RFC
const c2 = computed(() => x2.value * 2);
console.log(c2.value);
// Solid
const c3 = createMemo(() => x3() * 2);
console.log(c3());
// MobX
const c4 = computed(() => x4.data * 2);
console.log(c4.get());
// React (no actual getter but for comparison purposes)
const c5 = useMemo(() => x5 * 2, [x5]);
console.log(c5);
And here's what Effectful (impure) Computeds look like:
// KnockoutJS
computed(() => console.log(x1() / 10));
// Vue RFC
watch(() => console.log(x2.value / 10));
// Solid
createEffect(() => console.log(x3() / 10));
// MobX
autorun(() => console.log(x4.data / 10));
// React
useEffect(() => console.log(x5 / 10), [x5]);
Notice that Pure Computeds return accessors, whereas Effectful Computeds simply execute the code inside the function. Nothing technically prevents you from including side effects in your Pure Computeds, but it helps to recognize that these two serve quite different purposes.
All libraries basically follow a common pattern here. Some, like MobX, offer a broader API surface to support more sophisticated reactions right out of the box. Svelte cleverly hides its computed mechanism behind the compiler using $: labels. No matter how you look at it, the same two primitives underlie every one of these systems. Could we finally be witnessing the Grand Unified Theory of Front-End Development?

The Inner Workings
People used to call this magic and warned against trusting anyone with wands and flowing robes, but you might say the JavaScript community has outgrown its witch-burning phase. It's possible to discuss this openly and rationally without fearing the unknown. With React, Vue, Svelte, and the ongoing evolution of front-end tools over the past several years, the climate seems to have shifted. So let's dive in:
Automatic Dependency Detection
There it is. And we're all still here.
These days it's not particularly mysterious. Modern tools like Proxies and compilers take our JSX, Component Code, ES6 and transform it into something entirely different. So what exactly is Automatic Dependency Detection?
Remember how all observables have a getter or accessor? When executing, every computed registers itself globally. Whenever an observable is accessed, that observable adds itself to the computed's dependency list. A simplified implementation might look like:
let currentContext;
function observable(value) {
const subscribers = [];
return function() {
// setter
if (arguments.length) { /* update value & notify subs */ }
// getter
else {
if (currentContext) subscribers.push(currentContext);
return value;
}
}
}
function computed(fn) {
let value;
function execute() {
/* do some initialization/cleanup of previous run */
const outerContext = currentContext;
currentContext = execute;
value = fn();
currentContext = outerContext;
}
//initial run
execute();
return /* getter of value */
}
There are a couple of things to note upfront here. First, computations execute once at the beginning. This initial execution establishes the dependency graph, ensuring that when observables change, the computeds run again. During this initial run, the computed's function calls getters on the observables, creating subscriptions. When any observable updates, it notifies its subscribers and the computed re-executes.
Second, the context wraps each execution, which means you can nest computations within computations. This allows the reactive graph to form a hierarchy. Every context re-runs based on its own dependencies. A parent re-run will recreate all of its children, but a child or sibling re-evaluation won't trigger the parent to reconfigure.
The genuinely powerful part is that upon every re-evaluation, all dependencies are cleared and then rebuilt. Dependencies are therefore dynamic. If a conditional clause in your computation returns early, the dependencies from other branches never register. Re-evaluation only occurs if that condition actually changes. This enables dynamic dependency graphs and prevents unnecessary re-computation.
A Concrete Example
Suppose you want to display a user's name differently depending on the current UI mode. In one mode you show their username, and in the other you show their full name. I'll use Solid's syntax here since it makes reads versus writes quite clear (and it's the most similar to React Hooks):
const [showFullName, setShowFullName] = createSignal(true);
const [getUserName, setUserName] = createSignal('JSmith');
const [getFullName, setFullName] = createSignal('John Smith');
const getDisplayName = createMemo(() =>
showFullName() ? getFullName() : getUserName()
);
createEffect(() => console.log(getDisplayName()));
// console: John Smith
This isn't a particularly heavy operation to compute, but it's useful for illustrating how updates propagate. When this code resolves, you'll predictably see John Smith logged to the console. Now let's switch the display mode:
setShowFullName(false);
// console: JSmith
The sequence of events is as follows:
- The
ShowFullNameobservable's value gets set totrue. - It notifies its subscribing computations, prompting re-evaluation. In this case, the
DisplayNamecomputation first clears all its dependencies. Then it executes again, tracking new dependencies as it encounters them. - During execution it accesses
showFullNameandgetUserName, subscribing the computed to both. - The computed resolves to a new value and writes it to
DisplayName. This notifiescreateEffect, its subscriber, which then clears all its own dependencies. - The effect function runs, accessing
getDisplayName, re-subscribing itself, and loggingJSmithto the console.
That's all fairly straightforward. But what if the FullName then changes to include a middle initial:
setFullName('John R. Smith');
The FullName observable value updates. It informs its subscribers, though there aren't any, since that subscription was removed during the last DisplayName execution (which currently shows the UserName). No other code runs. The reactive graph correctly surmises that no further updates are needed right now.
If you flip ShowFullName back to true, you'll see the updated name. Or if the name were changed while ShowFullName was true, you'd observe the change immediately. But in this instance, the change is recorded yet not propagated. This is the essence of fine-grained change detection: it performs work, including branch evaluation, only when the specific values it depends on are truly modified.
What About Performance?
This seems like the right moment to address some concerns you might have. Everything involves tradeoffs and cost. On the positive side, updates are extremely fast, far faster than a naive diffing mechanism. Every node memoizes its result, providing shortcuts for evaluation. If you're used to React and the Virtual DOM, it's as if componentShouldUpdate is written for you automatically. Unnecessary updates simply don't happen without extra effort.
Some of you may recall React's early days when its creators highlighted the weaknesses of such reactive systems. Earlier versions of these libraries struggled because, while fine-grained reactivity allows phenomenal performance, it can create additional overhead if everything re-evaluates separately, maybe even a few times over. With React, scheduling an update means a single execution that reflects the stable state at completion. Most major reactive systems have solved this issue by now, ranging from deferred execution on the next micro-task (KnockoutJS), to transactions (MobX), to SRP clock cycles (S.js, Solid). Those problems are long behind us. Svelte even solves it with compiler-based ordering of the dependent statements in proper execution order.
It's worth mentioning that building the reactive graph adds some overhead, which might affect initial rendering speed. Over the past few years various techniques emerged to help, like pre-compilation, but it's still a consideration to keep in mind.
Fine-Grained Rendering and Lifecycles
Introductory tutorials often skip over the topic of fine-grained rendering. There's usually an impression that you'll use these techniques together with the traditional DOM rendering methods from your frameworks. That's definitely one approach, using fine-grained reactivity essentially as store technology. But this path also makes possible entirely different means of scheduling and managing rendering. This is where the approach really gets compelling.
Generally, fine-grained libraries take one of two rendering paths. Either they feed into an existing component system, where the render function is wrapped in a computed, or they integrate directly with the DOM binding layer. I'll cover both in more depth below.
Component-Bound Systems
This is where most libraries currently sit. The fine-grained reactions simply trigger the component system's normal update cadence. Fundamentally, the library still functions by top-down diffing and patching of the DOM tree for every update, typically via a Virtual DOM. The nice part about using fine-grained here is that change management is both automated and optimized, eliminating the need for shouldComponentUpdate.
MobX with React, Vue, modern Ember, and even Svelte essentially employ a variant of this pattern. Apart from store technologies like MobX and Vuex, these systems are scoped locally and usually have fairly shallow graphs. Svelte even manages to tuck observables and computeds within the compiler, since their scope isn't needed beyond the component's lifespan. The lifecycle-based disposal makes these systems very lightweight. Their Component boundaries are a bit heavier, however, because they generally resolve and then rewrap observables. You don't typically pass observables down as props; you bind their values and rewrap them within new locally scoped observables that a child component creates.
These systems don't really need many lifecycle hooks. Along with initial setup, they typically require some onMounted and onDestroy handling. Should you need before-update or after-update functionality, generic scheduling with computeds usually covers it. By relying on primitives independent of a component's lifecycle, you gain more composability. With computeds, scheduled timeouts, and microtasks, you can model any conventional update mechanism you might need.
This approach still calls for an adjustment in thinking since it guides you to reason about changes at a smaller scale. Even when the full render/template method re-runs and diffs, memoized computeds make it cheap to decide what needs updating. It appears as if only the changed parts update, because memoization frees you from manually reasoning about should-and-would updates.
Component-Less Systems
This style is older, yet persistent in a few niche libraries like Knockout, Solid, and Surplus. It approaches fine-grained from a purist angle. Every expression—or combination of expressions—in the view gets its own computed. These can still offer component-like composition, but a component in this context is just a function that runs once, creates its graph, and is done. The main benefit is these libraries don't pay overhead for arbitrary boundaries, since observability spans components entirely. Their granularity is fine enough that diffing or patching routines, like with Virtual DOM libraries, are unnecessary. This produces dramatic performance gains.

Top Libraries from JS Frameworks Benchmark
Note: The above shows a recent snapshot of the top 12 results from the JS Framework Benchmark, which compares more than 100 frontend libraries across a variety of tests. Ignore the first five, which are vanilla reference implementations using direct DOM manipulation. Except for domc and ivi, every other library depends on this Component-less Fine-Grained approach.
These systems take the concepts above even further, eschewing lifecycle functions for general scheduling, often using an onCleanup method. On the surface this resembles onDestroy, but it's not connected to a component's lifecycle; rather it's associated with the current computed's context. For onMounted and after-update scenarios, you'd use standard JavaScript scheduling like setTimeout. This also takes time to adjust to, but it can be hidden behind hooks to maintain the same API style as the component-based version. This approach is more universal since it can be used in any computed context, not just those with DOM-rendering side effects.
Comparing Approaches With an Example
Let's look at a simple interval-based timer for each approach. First, the classic React lifecycle way using Classes:
import React from 'react';
export default class Counter extends React.Component {
state = {
count: 0,
delay: 1000,
};
componentDidMount() {
this.interval = setInterval(this.tick, this.state.delay);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.delay !== this.state.delay) {
clearInterval(this.interval);
this.interval = setInterval(this.tick, this.state.delay);
}
}
componentWillUnmount() {
clearInterval(this.interval);
}
tick = () => {
this.setState({
count: this.state.count + 1
});
}
handleDelayChange = (e) => {
this.setState({ delay: Number(e.target.value) });
}
render() {
return (
<div>
<h1>{this.state.count}</h1>
<input
value={this.state.delay}
onInput={this.handleDelayChange}
/>
</div>
);
}
}
Next, Vue's new RFC uses Fine-Grained Components to achieve the same goal:
<template>
<div>
<h1>{{count}}</h1>
<input v-model="delay">
</div>
</template>
<script>
import { value, watch } from 'vue'
export default {
setup(props) {
const count = value(0);
const delay = value(1000);
watch(() => delay.value, (delay, prevDelay, onCleanup) => {
const interval = setInterval(() =>
count.value++
, delay);
onCleanup(() => clearInterval(interval));
});
return { count, delay }
}
}
</script>
Finally, here's Solid with pure Fine-Grained binding:
import {createState, createEffect, onCleanup} from 'solid-js';
export default function Counter(props) {
const [state, setState] = createState({
count: 0, delay: 1000
});
createEffect(() => {
const interval = setInterval(() =>
setState('count', c => c + 1)
, state.delay);
onCleanup(() => clearInterval(interval));
});
return (<div>
<h1>{state.count}</h1>
<input
value={state.delay}
onInput={({ target }) => setState('delay', target.value)}
/>
</div>);
}
While each framework has its own quirks, the fine-grained APIs generally align, even though their rendering engines differ. The distinctions multiply as you dig further, but one thing is clear: each computation and observable atom is self-contained and individually declarative, not spread across lifecycle functions or fragmented configuration options.
Wrapping Up
By now you should have a clearer picture of how Fine-Grained Reactive Programming has been shaping front-end development throughout 2019. The basics are covered, but much more remains to explore. See the list at the end for links to the libraries mentioned above.
Perhaps even more critical is that you can now see where the industry might be moving. The Vue RFC for a Function API caught many off-guard. It's obviously right once you understand Vue's internals and what this approach solves for the library. But a community grounded in being "not React" will take time to accept an API that resembles React's, despite Vue having a more legitimate claim to this fine-grained lineage.
To be honest, this may just be the latest trend in a long series of them. Maybe we're finally getting on par with what Steve Sanderson, KnockoutJS's creator, realized all the way back in 2010. Maybe you find this as deeply unappealing as I first did when React's lifecycle methods reminded me of ASP.NET webforms from the web's darker age. Regardless, one thing is clear: this shift is currently underway, and JavaScript libraries have never looked more alike.
