Inside the Core Concept of Fine-grained Reactivity
In the previous article, we challenged one of React’s foundational assumptions —
that state changes are unpredictable and therefore must be handled by re-executing components and diffing VDOM trees.
Before we dive deeper, let’s take a fresh look at how React actually processes state updates:
If you’ve been working with React for a while, this flow should look familiar.
It’s the classic render → diff → commit pipeline — the one you see in every interview whiteboard explanation.
Now let’s compare that to what happens in a fine-grained reactivity system:
To make the difference clearer, here’s a side-by-side comparison:
React vs. Fine-grained Reactivity
| Aspect | React (useState + Virtual DOM) | Fine-grained Reactivity (Signals/Atoms) |
|---|---|---|
| Update granularity | Component (entire subtree) | State cell (single value) |
| Dependency tracking | Re-run component → diff VDOM | Track on read, notify on write |
| Scheduling model | Async batch → diff → commit | Async batch → topo-sorted propagation |
| Idle cost | Re-run functions even if output is same | Short-circuits when result doesn’t change |
| Mental model | “UI = f(state)” | “State = f(source), UI is just an effect” |
| Optimization tools | memo, useMemo, useCallback | Mostly built-in; explicit memo only when crossing layers |
| DevTools ecosystem | Mature | Growing (Solid/Vue/MobX DevTools) |
Signals shrink the update boundary from “component” to “data”.
As the application grows, system complexity scales linearly, not exponentially.
Why Does useState Do “Extra Work”?
1. Function re-execution overhead
React updates UI by re-running the component function, generating a new VDOM tree, and comparing it to the previous one.
Deep component trees often exhibit a pattern you’ll recognize from the profiler:
Re-rendered but nothing changed visually.
Large applications pay this cost frequently.
2. Component boundaries ≠ data boundaries
Components often contain multiple pieces of state, styling, layout, and derived values.
Updating any of them forces the entire function to run again — unless you manually extract logic or add memoization.
Core Terminology and the Mental Model
| Term | React Analogy | What It Means |
|---|---|---|
| Source / Signal |
useState state variable |
The primitive, writable data node |
| Computed / Derivation | useMemo |
A cached pure function derived from Signals |
| Effect / Reaction | useEffect |
Runs side effects when dependencies change |
| Batch / Transaction | unstable_batchedUpdates |
Groups multiple writes into one propagation cycle |
| Graph / Dependency Map | React Fiber | A directed graph of data dependencies |
Mental model diagram
Example:
If Computed C depends on Source A and Source B, any change to A or B recalculates only C — and triggers Effect D only if C’s value actually changed.
No component re-renders unless D explicitly touches the DOM.
The Three Stages of Fine-grained Reactivity
(Shared by Solid.js, Vue 3, MobX, Jotai, Signals.js, etc.)
| Stage | Example | Internal Behavior |
|---|---|---|
| Read (Tracking) | console.log(count()) |
Runtime records “this computation depends on count” |
| Write (Mark Dirty) | count.set(v => v + 1) |
Updates value + marks dependent nodes as dirty |
| Propagate (Flush) | scheduler flush | Topo-sort: recompute Computeds → notify Effects if changed |
Short-circuiting:
If a Computed produces the same value (e.g., Math.floor()), the entire downstream chain stops.
Cost ≈ O(number of affected nodes).
Counter Example: React vs Fine-grained Reactivity
React
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: <span>{count}</span>
</button>
);
}
Clicking triggers:
- Re-run Counter
- Produce new VDOM
- Diff
- Commit updates
If this component sits inside a large table row, you’ll see the whole call stack light up in DevTools.
Solid.js (Fine-grained model)
const [count, setCount] = createSignal(0);
const Counter = () => (
<button onClick={() => setCount(c => c + 1)}>
Count: <span>{count()}</span>
</button>
);
Clicking only updates:
the text node bound to the signal
No re-running the component.
No diffing.
Why Fine-grained Reactivity Wins
1. Update cost decoupled from UI size
Only affected Computeds/Effects recompute.
Perfect for spreadsheets, dashboards, whiteboards, and large interactive UIs.
2. Predictable dataflow
The dependency graph is explicit — perfect for tracing, debugging, or implementing “Why did this update?” tools.
3. Side effects are isolated
Effects run only when their dependent values actually change.
Avoids the classic React useEffect dependency pitfalls.
4. Lighter testing
Computed values are pure functions →
You can test logic in Node without DOM mocks.
5. Incremental adoption
React: wrap in useSyncExternalStore.
Vue 3: built in.
Svelte/MobX: share the same mental model.
Common Questions & Trade-offs
| Question | Answer |
|---|---|
| How is this different from MobX/Proxy systems? | Signals use explicit getters/setters → predictable, avoids Proxy quirks. |
| Does it support async? | The core is synchronous. Async is handled via resource/async utilities but still propagates through the same graph. |
| Do we still need Context? | Signals can be passed across layers. Context is optional unless integrating with React DevTools. |
| Is it always faster? | For highly interactive UIs → yes. For static pages or one-shot renders, React diffing is usually “fast enough.” Depends on the use case. |
Conclusion
The power of fine-grained reactivity comes from shrinking “recomputation” down to the smallest possible unit of data.
Instead of forcing UI updates to follow component boundaries, Signals treat reactivity as a graph + scheduler problem — a much more scalable mental model.
In the next article, we’ll explore the philosophy and evolution of fine-grained reactivity, and how different frameworks arrived at similar ideas from very different directions.


Top comments (0)