JavaScript Reactivity (Part 3): Coarse vs. Fine-Grained Reactivity
Published 2026-06-06, About 10 minute read.
In Part 2 we framed reactivity depth with a question: "What is the smallest change I can make that still triggers updates in the reactivity system?" In this part granularity has two corresponding questions:
- "At what level are changes detected?"
- "How does the reactivity system detect what actually changed?"
The first question is about scope- where does the system have to look to figure out what changed? The second is about mechanism- what technique does it use within that scope? The radar diagram below plots scope on the rings (window, application, service, component, template, DOM node) and lets you click a framework to read how its mechanism lands it there.
Let's work our way from the outside of the onion in. This means we're starting with coarse and moving inward to fine.
The coarsest of the coarse: zone.js 📎
Let's start with the weirdest one in this category: Zone.js. This library monkey patches and listens in to all asynchronous processes in the browser (!). The idea is that if anything happens, it likely has a related state change that needs to be figured out and consequently a UI update that needs to be applied. When this "something changed" notification happens, Angular has no idea what's changed. It has to instruct components to check for dirtied data, and then recursively call for the same type of checking and refreshing across the app.
You might think this is extremely expensive to walk the entire application tree- it could be thousands and thousands of components that we've rendered! Well, for each of those checks we're just doing a quick comparison, which is cheap. But also there are some escape hatches that help prune sections of the tree. (If you're interested, check out ChangeDetectionStrategy.OnPush.)
Coarser: the more well-known re-render and diff strategy 📎
The next layer, the application layer, is the spot for React,
React + Immer, Preact (without signals), Redux, and Zustand. It's a React
party.
These libraries generally get the notification that state has updated and re-render everything! The reason this is performant is because this strategy relies on rendering a virtual dom which then can participate in a process called reconciliation in which React compares actual DOM nodes with virtual DOM nodes to see what should be patched, deleted, or added. And because there's a diff that filters out the vast majority of candidate nodes that need to be put in the DOM, the set of actual changes remains very small.
This is very coarse still! If the entry point to a React or Preact application has state that changes, the rerendering that occurs afterwards happens recursively down all the component branches in the app tree. The comparison afterward is what makes this strategy tenable by doing quick reference equality checks and pruning large branches of the component tree where changes wouldn't actually make UI changes.
You could argue that this is more of a component-level strategy. It is true that if a state hook further down a branch of react updates, the recursive re-rendering only happens from that branch node down the branches underneath that node. But, the fact that whole sections of the tree (and possibly even the entire tree depending on the state's placement) could be re-rendered makes it seem that the whole application participates in this re-rendering for correctness.
Similarly, in Redux or Zustand, these change notifications are app-wide. You can reduce the number of branches that re-render using selectors. But at the end of the day, any dispatch to the reducer basically makes any state a candidate for changes. React can only know when something has changed by doing the re-render dance at scale and shake out the actual changes with a diff.
The in between: wc-services, Lit, Ember, and Vue 📎
I placed wc-services in the service layer because, although it is very coarse with notifications when any state changes (and no communication of what changed where), only components or classes subscribed to the service would receive notifications of changes. Also, importantly, only service dependency chains would trigger notifications for subscribers in that chain. There is a "splitting" of reactivity notifications that happens that is more fine than what Redux or Zustand does.
Lit is really similar to React. When changes trigger re-renders, the component re-renders completely but smartly. It doesn't have the same sort of reconciliation as React, but bakes in value comparisons in the rendering template internals. So the reactivity is quite coarse. The reason it isn't further out on this radar graph is that Lit restricts reactivity primitives to inside of custom elements. This means that state changes can never transcend above a component. The most a component can do is subscribe to external changes and call `this.requestUpdate()` when it's notified by outside state changes.
That leaves Vue and Ember. They are very similar in how they handle changed state: both invalidate specific parts of data and both will notify the specific region where the data is rendered.
Ember increments a clock on tracked data. When a template renders tracked data, the data is associated with the template that it was rendered in. When data changes, Ember schedules a global "let's re-render" event, and templates are checked quickly to see which templates need to be rendered by using those tracked property clocks. In the end, only the templates with incremented tracked data re-render, and the updates are precise. Ember has the distinctive that components are almost incidental- templates can be rendered without a backing class, from a route, or in a component. This is why templates are distinct from components in our analysis.
Vue has a really cool distinctive: only properties that are actually consumed
are tracked. As we saw in my last post, when Vue wraps data it by default
recursively proxies data access. This means that in the template of a Vue
component, if you access data.a but don't access
data.b, only a change in data.a would notify the
component that it needs to re-run its template.
The fine-grained end: track every read 📎
Now we shall enter the inner sanctum: the fine-grained reactivity. Behold, my compare and contrast table.
As we approach the inner DOM Node circle, we see frameworks basically doing the same thing: if you read a value it subscribes you to it. When you write to the value, it notifies the pertinent parts of the framework.
| Angular signals | Solid signals | Svelte 5 runes | Preact signals | |
|---|---|---|---|---|
| Read | count() |
count() |
count (plain var) |
count.value |
| Write | count.set(1) |
setCount(1) |
count = 1 |
count.value = 1 |
| Create |
signal(0)
|
createSignal(0)
|
$state(0)
|
signal(0)
|
| Derived |
computed(...)
|
createMemo(...)
|
$derived(...)
|
computed(...)
|
| Effect |
effect(...)
|
createEffect(...)
|
$effect(...)
|
effect(...)
|
| Compiled or runtime? | Runtime | Runtime | Compiler- runes are compile-time | Runtime |
| Depth for objects | Shallow |
Shallow (createStore
for deep)
|
Deep- proxies nested data | Shallow |
| What re-runs on a write | Marks the component dirty, re-renders it | The bound DOM node- no diff | The bound DOM node- no diff | The bound node if used directly, otherwise the component |
| A diff step? | Yes | No | No | Only when it falls back to a component render |
| Where it comes from | Angular core (v16+) | Solid's core primitive | Svelte 5 core | Standalone- runs in React too |
While I'm saying Angular is "DOM Node fine-grained," it's important to note that the entire Angular component re-runs even with signals. The same thing can happen with Preact when you don't use the text node optimization.
The tradeoffs 📎
Coarse granularity has some perks: it's simpler and easier to implement. You can for the most part get away with naive coarse granularity and take it very far before you need to start worrying about performance.
Fine granularity exists for a reason though: it's super efficient and the primitives are more portable. However, there are some gotchas you have to worry about with fine granularity that you don't with coarse.
Fine grained strategies suffer from a particular anxiety: dependency tracking. When a piece of data is read, because the whole world is not re-rendered and only targeted pieces that rely on this data, the system needs to manage subscribing, unsubscribing, and de-duping these dependencies.
For example, in an effect(), suppose we reference a signal twice.
We would need to make sure that the subscription is batched and the fact that
we referenced the same signal twice would not cause a double call of the
effect on this signal change. We also need to worry about conditionally called
data values in effects. What if the effect should be re-run only sometimes,
like in this code?
But probably more difficult to settle is the diamond problem.
This occurs when B and C both depend on D, and A depends on both B and C. If D
changes, we have two paths to notify: D → B → A or
D → C → A. To make sure that data is resolved correctly (or
eventually correctly) requires strategies like
topological sorting. There is a well-known problem with this sort of dependency issue called
glitches. I won't go into this more, but you can read what Ryan Carniato (Solid.js
creator) has to say about this
here.
For the sake of time, I'm not going into Svelte4, Mobx, or nanostores. Sorry if you're a fan. 😅 Svelte 4 is worth checking out if you are interested- it famously "doesn't have a runtime." What this means is that changes are fine grained and written (compiled) into the code that runs at run time. Play around with examples like this and take a gander at the "js output" if you want to see some of the unique internals.
What's next? 📎
So that's granularity. Coarse is simpler and gets you surprisingly far. Fine-grained does less work but asks you to manage dependencies.
Next up is push vs. pull reactivity. When something changes, does the source shove the update out to subscribers, or do consumers ask for the value when they need it? As it turns out, most frameworks do a littl of both! See you then!
If you like my work, please let me know! Find me on Bluesky or Mastodon. I also have an RSS feed here