Skip to content

React's Re-render Model vs Signals

expert18 min read

Two Philosophies Walk Into a Render Cycle

Every frontend framework answers one question: when state changes, what updates?

React says: "I don't know what changed, so I'll re-run your component function and diff the output." Signals say: "I know exactly what changed because every read was tracked, so I'll update only those specific spots."

Neither is wrong. They're different answers to a deeply fundamental tradeoff, and understanding why each model exists will change how you think about frontend architecture forever.

// React: state changes → component re-executes → virtual DOM diffs → DOM patches
function Counter() {
  const [count, setCount] = useState(0);
  console.log('Component re-executed');
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// Signals: state changes → only subscribers of that signal update → direct DOM patch
function Counter() {
  const count = signal(0);
  console.log('This runs once. Ever.');
  return <button onClick={() => count.value++}>{count}</button>;
}

That console.log difference is everything. In React, it fires on every click. In a signal-based framework, it fires once when the component mounts, and never again.

The Mental Model

Mental Model

Think of React like a newspaper printing press. When any story changes, the press prints a new edition of the entire page. Then an editor compares the old edition with the new one, finds the differences, and only delivers the changed sections to readers. The printing is cheap (virtual DOM), the comparison is fast, and you never have to track which readers care about which stories.

Signals are like a notification system. Each reader subscribes to specific stories they care about. When a story updates, only subscribed readers get notified, and they get exactly the update they need. No reprinting, no diffing, no wasted work. But you need the subscription infrastructure.

React's Model: Render Everything, Diff the Output

React's core bet is radical simplicity. Your component is a function from state to UI:

UI = f(state)

When state changes, React calls f again. That's it. There's no subscription tracking, no dependency graph, no special reactive primitives. Just a function that returns what the UI should look like.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('dark');

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <Skeleton />;

  return (
    <div className={theme}>
      <Avatar src={user.avatar} />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      <ThemeToggle onToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} />
    </div>
  );
}

When theme changes, React re-runs this entire function. It creates a new virtual DOM tree, diffs it against the previous one, and patches only the changed DOM nodes. The Avatar and h1 and p elements didn't change, so the real DOM for those stays untouched.

Why React chose this

  1. Correctness by default -- you can't forget to subscribe to something. If you read user.name, it's in the output. Period.
  2. Simplicity -- no reactive primitives to learn. useState returns a value and a setter. That's the entire state API.
  3. Predictability -- given the same props and state, a component always produces the same output. Pure functions are easy to reason about.
  4. Ecosystem -- any JavaScript expression works in JSX. No compiler magic, no proxy traps, no special syntax.

What it costs

The re-render model has a tax. When a parent re-renders, all children re-render too, even if their props haven't changed. React doesn't know if your child's output would change without actually calling the function.

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Click: {count}</button>
      <ExpensiveChart data={chartData} />  {/* Re-renders on every click */}
      <HeavyTable rows={tableRows} />      {/* Re-renders on every click */}
    </div>
  );
}

Every click re-renders ExpensiveChart and HeavyTable, even though chartData and tableRows never change. You know it, I know it, but React doesn't.

The solution? Manual optimization:

const MemoizedChart = React.memo(ExpensiveChart);
const MemoizedTable = React.memo(HeavyTable);

function App() {
  const [count, setCount] = useState(0);
  const chartData = useMemo(() => computeChartData(), []);
  const tableRows = useMemo(() => computeTableRows(), []);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Click: {count}</button>
      <MemoizedChart data={chartData} />
      <MemoizedTable rows={tableRows} />
    </div>
  );
}

That's three optimization annotations (memo, useMemo, useMemo) for something that signals handle automatically. This is the crux of the debate.

Quiz
In React, when a parent component re-renders, what happens to its child components by default?

Signals Model: Track Reads, Update Writes

Signals flip the model. Instead of re-running functions and diffing output, signals track exactly which computations depend on which state. When state changes, only the specific computations that read that state re-execute.

const firstName = signal('Alice');
const lastName = signal('Smith');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

effect(() => {
  document.title = fullName.value;
});

Here's what the reactivity system knows after running this code:

  • fullName depends on firstName and lastName
  • The effect depends on fullName
  • When firstName.value = 'Bob', the system walks the dependency graph: firstName changed → fullName needs recomputing → the effect needs re-executing → document.title updates to 'Bob Smith'

lastName subscribers? Untouched. Any other signals in your app? Completely unaware anything happened. This is fine-grained reactivity.

The subscription graph

Under the hood, every signal maintains a set of subscribers. Every computation (computed values, effects) maintains a set of dependencies. Reading a signal inside a tracking context (a computed or effect) creates a bidirectional link:

firstName ←→ fullName ←→ titleEffect
lastName  ←→ fullName

When firstName changes:

  1. Notify fullName it's dirty
  2. fullName re-evaluates, gets new value
  3. Notify titleEffect its dependency changed
  4. titleEffect re-executes with new fullName

No component re-renders. No virtual DOM diff. No wasted work. The update propagates through the graph like electricity through a circuit.

Quiz
When firstName.value changes in the signals example above, which computations re-execute?

The Optimization Burden: Manual vs Automatic

This is the practical difference that matters most in production code. Let's compare how each model handles a common scenario: a dashboard with many widgets where only one piece of data changes.

// React: you must manually optimize
function Dashboard() {
  const [notifications, setNotifications] = useState(0);
  const [user, setUser] = useState(null);
  const [analytics, setAnalytics] = useState([]);

  return (
    <div>
      <NotificationBadge count={notifications} />
      <UserProfile user={user} />
      <AnalyticsChart data={analytics} />
      <ActivityFeed />
      <QuickActions />
    </div>
  );
}

When notifications increments (say, every few seconds from a WebSocket), React re-renders Dashboard, which re-renders all five children. You need React.memo on each child, useMemo for complex prop objects, and useCallback for handler props. Miss one, and you silently degrade performance.

// Signals: optimization is structural
const notifications = signal(0);
const user = signal(null);
const analytics = signal([]);

function Dashboard() {
  return (
    <div>
      <NotificationBadge />
      <UserProfile />
      <AnalyticsChart />
      <ActivityFeed />
      <QuickActions />
    </div>
  );
}

function NotificationBadge() {
  return <span>{notifications.value}</span>;
}

When notifications.value changes, only the span inside NotificationBadge updates. UserProfile, AnalyticsChart, and every other component? Their functions don't re-execute. Ever. No memo, no useMemo, no useCallback. The granularity is baked into the model.

Common Trap

The React Compiler (React Forget) aims to auto-memoize components and eliminate the manual memo/useMemo/useCallback burden. If it ships fully, React's optimization story improves dramatically. But here's the subtle difference: the Compiler makes React better at avoiding unnecessary re-renders. Signals avoid the concept of re-renders entirely. Even with perfect memoization, React still calls component functions and diffs virtual DOM -- it just does it less often. Signals skip both steps and go straight to DOM mutation.

When Each Model Wins

DimensionReact Re-render ModelSignals Push Model
Mental modelSimple: UI = f(state)Graph: state → derived → effects
Default performanceO(tree size) per update, needs memoO(affected nodes) per update, automatic
Correctness guaranteeCannot forget subscriptionsCan leak subscriptions if cleanup is wrong
DebuggingTop-down, deterministic re-rendersGraph traversal can be hard to trace
Ecosystem sizeMassive (React dominance)Growing (Solid, Vue, Angular, Preact)
Server renderingRSC, streaming, Suspense -- deep investmentVaries by framework, catching up
Concurrent featuresTransitions, Suspense, time-slicingLimited -- most signal systems are synchronous
Learning curveLower (just functions)Higher (reactive primitives, tracking rules)

React wins when

  • Your team values simplicity and predictability above raw performance
  • You need concurrent features (transitions, Suspense boundaries, streaming SSR)
  • Your app is mostly server-rendered content with islands of interactivity
  • You're building with React Server Components where re-renders happen on the server

Signals win when

  • Your app has frequent, localized state updates (dashboards, real-time data, editors)
  • You want performance without manual optimization annotations
  • You're building highly interactive UIs where every millisecond of update latency matters
  • Your team is comfortable with reactive programming mental models
Quiz
Which scenario would benefit MOST from signals over React's re-render model?

The Convergence

Here's the twist: React and signals are converging.

React's direction with the React Compiler is to automatically infer which parts of a component tree are affected by a state change and skip re-rendering the rest. That's essentially what signals do, just achieved through compile-time analysis instead of runtime tracking.

Meanwhile, signal-based frameworks are adopting React's ideas about server rendering, streaming, and Suspense-like async boundaries. SolidJS 2.0 adds concurrent transitions. Vue 3.6 integrates alien signals for better performance while keeping its component model.

The TC39 Signals proposal (Stage 1) aims to standardize the reactive primitive in JavaScript itself, which would allow frameworks to share a common reactivity foundation. If adopted, you might use signals in React, Solid, Vue, and Angular all backed by the same engine-level primitive.

Why did React not adopt signals?

Dan Abramov addressed this directly: React's architecture is designed around the ability to pause, abort, and resume rendering (concurrent features). Signals are inherently synchronous -- when a signal changes, subscribers execute immediately. React's scheduler needs the freedom to defer updates, batch across multiple state changes, and prioritize user interactions over background work.

Signals also create implicit dependencies. If you read signal.value inside a function, that function is now subscribed. This works great for performance but creates invisible coupling. React's explicit model (you pass props, you read state) makes data flow visible in code review.

The React team's bet is that the Compiler can give you signal-like performance within React's existing model, without changing the programming model developers already know.

Key Rules
  1. 1React re-renders the component tree top-down, signals propagate changes through a subscription graph bottom-up
  2. 2React trades performance for simplicity (opt-in optimization), signals trade simplicity for performance (automatic optimization)
  3. 3Neither model is universally better -- the right choice depends on your app's update patterns and your team's mental model preferences
  4. 4The two models are converging: React Compiler adds automatic optimization, signal frameworks add concurrent/async features
What developers doWhat they should do
Signals are always faster than React
Performance depends on the workload pattern, not just the reactivity model
Signals have lower update overhead, but React's batching and concurrent features can outperform naive signal usage in complex async scenarios
React re-renders are expensive and should always be avoided
The virtual DOM exists precisely to make re-renders cheap. The problem is excessive re-renders causing excessive diffing, not re-renders themselves
React re-renders are cheap (just function calls returning objects). Only DOM mutations are expensive, and React minimizes those via diffing
Wrapping everything in React.memo solves React's performance problems
Memo has a cost: it runs a shallow comparison on every render. If props change frequently, you pay the comparison cost AND the re-render cost
Over-memoization can hurt performance due to comparison overhead and prevents React from batching updates optimally
1/10