EmpireUI
Get Pro
← Blog8 min read#react#solidjs#fine-grained-reactivity

Fine-Grained Reactivity vs Virtual DOM: What Actually Re-Renders

Fine-grained reactivity and virtual DOM differ wildly in what triggers a re-render. Here's what React, Solid, and Svelte actually do under the hood.

Abstract visualization of data flow nodes and connections representing reactive programming and rendering systems

The Two Mental Models Behind Modern UI Rendering

Honestly, most React developers have been thinking about rendering wrong for years — not because React is broken, but because the virtual DOM abstraction hides a lot of what's actually happening. You write JSX, something re-renders, the screen updates. The details feel irrelevant until they aren't.

Fine-grained reactivity takes a completely different philosophy. Instead of re-running component functions to produce a new virtual tree, it tracks exactly which pieces of state are read by which pieces of the DOM. When state changes, only the affected nodes update. No diffing. No reconciler.

Both approaches produce correct UIs. The performance characteristics, though, diverge dramatically at scale. Understanding each model makes you a better developer in either ecosystem — and helps you make smarter choices when picking a framework for your next project.

How React's Virtual DOM Diffing Actually Works

React's model is conceptually simple: when state changes, re-run the component function, produce a new virtual DOM tree, diff it against the previous tree, apply only the changed patches to the real DOM. That diffing step is called reconciliation, and React 18's concurrent renderer made it interruptible — meaning it can pause work mid-diff to handle higher-priority updates.

Here's what this means in practice. If you have a <UserList> component with 500 items and one item's name changes, React re-runs UserList, re-runs all 500 <UserCard> children unless they're memoized, diffs 500 virtual nodes, and patches exactly one real DOM node. The reconciler is fast — but you're still doing work proportional to the component tree size, not the number of actual changes.

The optimization story in React is therefore about *reducing the component tree that re-runs*: React.memo, useMemo, useCallback, splitting components to create natural bailout boundaries. If you've ever wondered why Empire UI's theme toggle implementation wraps so many things in memo, this is exactly why — context changes fan out to every subscriber unless you explicitly block them.

Fine-Grained Reactivity: Signals All the Way Down

SolidJS popularized fine-grained reactivity in the JavaScript ecosystem, though the concept traces back to Knockout.js from 2010 and before that to spreadsheet engines. The core primitive is a *signal* — a reactive cell that tracks which computations read it. When a signal updates, only those specific computations re-run.

In Solid, component functions run exactly once. That's it. No re-renders. The JSX you write isn't producing a new virtual tree on each state change; it's setting up reactive bindings directly against real DOM nodes. When count() changes inside a <span>{count()}</span>, Solid updates that text node directly.

Svelte takes a compile-time variation of this idea. It analyzes your $state declarations and reactive statements at build time, then emits vanilla JS that updates the DOM surgically. No runtime reactivity graph — the graph is baked into the generated code. The result is tiny bundle sizes and zero overhead for the reactivity system itself.

A Concrete Comparison: Counting Updates

Let's look at what actually fires when a counter increments, comparing React and Solid side by side.

// React — everything inside Counter re-runs on each click
function Counter() {
  const [count, setCount] = React.useState(0);

  // This console.log fires on EVERY re-render
  console.log('Counter rendered');

  return (
    <div className="flex items-center gap-2 p-4">
      <button onClick={() => setCount(c => c + 1)}>+</button>
      {/* Both the div AND the span re-reconcile, even if only count changed */}
      <span>{count}</span>
    </div>
  );
}

// Solid — component body runs ONCE, only the span's text node updates
function Counter() {
  const [count, setCount] = createSignal(0);

  // This only logs once — on mount
  console.log('Counter setup');

  return (
    <div class="flex items-center gap-2 p-4">
      <button onClick={() => setCount(c => c + 1)}>+</button>
      {/* Only this text node is touched when count() changes */}
      <span>{count()}</span>
    </div>
  );
}

Notice the parentheses: count() vs count. In Solid, reading a signal inside JSX establishes a subscription. That function call is what creates the reactive binding. Skip the parens and you get the raw value at mount time — no reactivity. It's a small syntactic cue that encodes a big semantic difference.

Where the Virtual DOM Still Wins

It's tempting to read all this and conclude the virtual DOM is just overhead. It's not. The model has real advantages that fine-grained reactivity struggles with.

First, composability. React's component model makes it trivially easy to pass render functions, compose behaviors, and abstract patterns without any mental model of subscriptions or ownership. Libraries like Framer Motion and React Spring work because they can intercept the render cycle at a predictable point. Building equivalent animation libraries on top of Solid requires thinking in signals the whole way down.

Second, server rendering. React 18's streaming SSR and React Server Components are built around the component-function model. The virtual DOM diffing is what makes hydration work — the server produces HTML, the client reconciles it against a re-run component tree. Fine-grained reactivity frameworks have server rendering support, but the patterns are less mature and the tooling ecosystem is significantly smaller.

Third, tooling and ecosystem maturity. If you're building a SaaS with a team of five developers who already know React, the performance delta between virtual DOM and fine-grained reactivity almost certainly doesn't matter as much as the velocity and hiring pool. That's not a performance argument. It's an engineering one.

Measuring Real Re-Render Costs in React

React DevTools Profiler is the right tool here — not guessing. Record an interaction, look at the flame chart, and count how many components render in grey (bailed out via memo or unchanged props) versus orange (actually ran their function body). If you're seeing most of your tree in orange for a simple state change, you've got work to do.

The numbers that matter: a component function that completes in under 1ms is essentially free. 5ms starts to accumulate if dozens fire in parallel. 16ms is your frame budget at 60fps — blow that and you're visually dropping frames. React's concurrent mode helps by spreading expensive reconciliation across idle frames, but it doesn't eliminate it.

One pattern worth knowing: splitting a component that reads context into two — one that reads the context and passes it as a prop, one that renders the UI — can halve your reconciliation cost when context updates frequently. Pair this with CSS Modules or Tailwind for style isolation and you'll often get the surgical update behavior you want without switching frameworks.

Also worth profiling: how many renders does your glassmorphism UI or card grid produce when a filter state changes? If it's proportional to the number of cards rather than the filter change itself, you're paying the virtual DOM tax unnecessarily. That's a useMemo on the filtered list and React.memo on the card component.

Hybrid Approaches: React Compiler and Signals Polyfills

The React team shipped the React Compiler (formerly React Forget) in React 19 to close some of this gap. It analyzes your component code at compile time and automatically inserts memoization — essentially deriving the useMemo and useCallback calls you should have written yourself. It's not signals, but it reduces the manual optimization burden significantly.

There's also @preact/signals-react, a library that drops fine-grained signals into React. You can use signal(0) instead of useState(0), and components that read that signal only re-render when it changes — bypassing React's normal reconciliation for those specific values. It's a polyfill for the mental model, not a framework replacement.

Are these hybrid approaches better than just using Solid or Svelte? Depends entirely on your constraints. If you're already deep in a React codebase with a hundred components, adopting signals incrementally via @preact/signals-react is a practical move. Starting fresh on a performance-sensitive dashboard? Solid or Svelte is worth the learning curve. There's no universal answer here — which is exactly why understanding both models matters.

Practical Guidelines: When to Care About This

Most apps don't need to care. A marketing site, a blog, a settings page — none of these have rendering performance problems. You'll be fighting bundle size and network latency long before re-render overhead becomes measurable. Don't optimize what isn't broken.

The scenarios where this genuinely matters: real-time data tables updating at 60Hz, collaborative editing where many cursors update simultaneously, data-heavy dashboards with live charts (check out WebGL background effects for the GPU-side of that story), and animation systems where state changes every 16ms. In these cases, the difference between reconciling 200 components and patching 3 DOM nodes is the difference between a smooth experience and a janky one.

If you're building UI components with Empire UI on top of React, the library's component architecture is already designed with bailout boundaries in mind. Individual components don't consume global context — they accept props, which means React.memo actually works without needing deep equality checks. That's a deliberate design choice that keeps render cost predictable as your app grows.

FAQ

Does React's virtual DOM actually touch the real DOM every render?

No. React diffs the new virtual tree against the previous one and only applies changes that differ. However, the diffing itself runs on every render — you're paying CPU cost even if zero real DOM nodes change. That's the overhead fine-grained reactivity avoids.

Are Solid signals faster than React useState in benchmarks?

Yes, in microbenchmarks Solid consistently outperforms React, often by 2-5x on update-heavy scenarios. The js-framework-benchmark shows Solid near the top alongside Svelte and vanilla JS. React with the compiler enabled closes the gap but doesn't eliminate it. For most apps this gap is irrelevant; for data-dense UIs it's real.

Can I use fine-grained reactivity in an existing React app?

Yes. Libraries like @preact/signals-react (version 1.3+) let you use signals inside React components. Components that read a signal will bypass React's reconciler for that value. It's not a complete solution — the React component tree still runs — but hot-path updates become surgical rather than cascading.

What does React.memo actually protect against?

React.memo prevents a component from re-running its function body if its props haven't changed (by shallow equality). It doesn't prevent React from visiting the component during reconciliation — React still checks if props changed. The savings come from skipping the function body and all its children. If your props contain new object or array references on every render (common with inline objects in JSX), memo gives you nothing.

How does Svelte's reactivity differ from SolidJS signals?

Svelte's reactivity is compile-time: the Svelte compiler analyzes your $state declarations and $derived expressions, then emits JavaScript that directly updates DOM nodes. There's no runtime reactivity graph at all — it's all generated code. SolidJS has a runtime signals graph that tracks dependencies dynamically. Both achieve fine-grained updates but through different mechanisms; Svelte tends to win on bundle size, Solid on dynamic dependency tracking.

Does React 19's compiler make fine-grained reactivity irrelevant?

Not really. The React Compiler automates memoization but doesn't change the fundamental model: component functions still re-run, virtual DOM still gets diffed. It removes a lot of manual work and eliminates common perf mistakes, but the ceiling for optimization is still lower than true fine-grained reactivity. For most apps the compiler is a big win. For the highest-performance use cases, the architectural difference still matters.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

SolidJS Introduction: Fine-Grained Reactivity Without a VDOMCore Web Vitals in 2026: LCP, INP, CLS with Real Next.js FixesSolidJS vs React: Fine-Grained Reactivity vs Virtual DOMReact Render Performance: Profiler, Optimizations, Real Numbers