DEV Community

Luciano0322
Luciano0322

Posted on

Why Do We Need Signals?

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:

react state update

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:

signals update

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

signal mental

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

Clicking triggers:

  1. Re-run Counter
  2. Produce new VDOM
  3. Diff
  4. 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>
);
Enter fullscreen mode Exit fullscreen mode

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)