A price changes, and an order total updates. That tells you nothing about whether a component function ran, a computed value was recalculated, or a DOM node was modified. Those are separate kinds of work.

Signals are useful because they make dependencies between values explicit to a reactive runtime. React's ordinary state model instead schedules component rendering when state changes. Neither model, by itself, tells you how many browser DOM operations will occur or which approach will be faster in your application.

The practical question is not "signals or the Virtual DOM?" It is where does the work happen, and how does the runtime know what to update? We'll use a small, inspectable example with @preact/signals-core, then compare its behavior with React and a signal-aware view renderer. This is an architectural comparison, not a performance benchmark.

Track three kinds of work separately

Consider an order summary with a price, a discount percentage, and a switch that determines whether the discount applies.

There are at least three distinct steps between changing an input and displaying a new total:

  1. Invalidation: Something marks a dependent calculation or view as potentially out of date.
  2. Computation: The runtime evaluates the necessary expression to obtain a current value.
  3. Rendering and commit: A UI renderer determines what to display and may update the DOM.

React and signal libraries make different choices about the first two steps. The third depends on the renderer. An individual signal can power a component render, a direct DOM text binding, or even a calculation with no UI at all.

In React, calling a state setter schedules work for a component. During rendering, React calls components as needed to calculate their output; a later commit applies necessary DOM changes. A render does not imply that React changed the DOM. Components can also avoid some work through state identity, memoization and compiler optimizations. See the React render and commit documentation and the separate guide to React rendering and Fiber.

In a signal system, reading a signal inside a tracked computation establishes a dependency. A write can invalidate the computations that read it. Invalidation is not always immediate re-execution: computed signals are often cached and evaluated when a consumer needs their value. The TC39 Signals proposal describes this as a push-pull model, not a purely push-based replacement for rendering.

Run a dependency graph with a changing branch

This example uses the framework-independent @preact/signals-core package, so React, Preact and the DOM are not involved. It tests something more precise than a visual counter: a signal should not subscribe to a value its current computation never reads.

In an empty Node.js project, install the package:

npm install @preact/signals-core@1.14.4

Save this as signals-demo.mjs and run node signals-demo.mjs. The example targets @preact/signals-core 1.14.4 and uses Node's built-in assertion module. The expected final output is [ 100, 50, 60 ] when all assertions pass.

import assert from 'node:assert/strict';
import { signal, computed, effect, batch } from '@preact/signals-core';

const price = signal(100);
const discount = signal(0.2);
const applyDiscount = signal(false);

const total = computed(() => {
  if (!applyDiscount.value) return price.value;
  return price.value * (1 - discount.value);
});

const observed = [];
const dispose = effect(() => {
  observed.push(total.value);
});

assert.deepEqual(observed, [100]);

// discount is not read while applyDiscount is false.
discount.value = 0.5;
assert.deepEqual(observed, [100]);

// This read now adds discount to the dependency graph.
applyDiscount.value = true;
assert.deepEqual(observed, [100, 50]);

// Two writes, one final observation from the effect.
batch(() => {
  price.value = 80;
  discount.value = 0.25;
});
assert.deepEqual(observed, [100, 50, 60]);

dispose();
price.value = 40;
assert.deepEqual(observed, [100, 50, 60]);

console.log(observed);

What this demonstrates, if the assertions pass in your installed package version: the dependency set is established by actual reads; the effect observes the derived value; batch() groups notifications; and disposing the effect stops later observations. The example is an executable check you can run, not a claimed test result from this portfolio or a browser benchmark. Its API and intended batching behavior match the official Signals Core reference.

The interesting moment is the first discount.value = 0.5. Because the current branch returned before reading discount, changing that signal should not prompt a new observed total. Once applyDiscount becomes true, the computation starts reading discount, so the dependency graph changes.

The same concept appears in the Angular Signals documentation, which explicitly documents dynamic dependencies and cached, lazy computed values. This is a property of tracked reads, not a special ability to avoid all JavaScript work.

Why an effect is not just a computed value

A computed signal represents derived state: it returns a value and can cache it. An effect performs external work such as updating a non-reactive API or reporting an observation. In the example, total holds the calculation; the effect records the result. The effect is also why we have something observable after each write.

Without an active consumer, a lazily evaluated computed signal need not recalculate just because one of its sources changed. Conversely, an effect that reads too many signals can become an unnecessarily broad subscriber. Avoid using effects to copy derived values into additional writable state unless there is a concrete synchronization reason.

The cleanup matters too. A long-lived effect retaining a component, listener, or resource can outlive the UI that created it. Use the lifetime management provided by your framework, or call the disposer for a manually created effect. Fine-grained tracking is not automatic memory management for every external resource.

How does the same change travel through React?

Here is a deliberately small React component displaying the same derived total:

import { useState } from 'react';

export function OrderSummary() {
  const [price, setPrice] = useState(100);
  const [discount, setDiscount] = useState(0.2);
  const [applyDiscount, setApplyDiscount] = useState(false);

  const total = applyDiscount
    ? price * (1 - discount)
    : price;

  return (
    <section>
      <output aria-label="Order total">{total.toFixed(2)}</output>
      <button onClick={() => setPrice(80)}>Change price</button>
      <button onClick={() => setDiscount(0.25)}>Change discount</button>
      <button onClick={() => setApplyDiscount(value => !value)}>
        Toggle discount
      </button>
    </section>
  );
}

Calling setDiscount schedules an update to OrderSummary even when applyDiscount is false. React may call the component to calculate its output, but the unchanged output text does not require a DOM text mutation. That distinction is central to the React rendering model.

There is no need to store total separately in React state. It is derived from three existing values and can be calculated during rendering. If the calculation becomes expensive, measure first and then consider memoization or changes to component boundaries. React's memo reference explains both the optimization and why skipped rendering is not guaranteed.

This comparison is intentionally not apples-to-apples execution timing. The first sample exercises a state graph outside a UI; the second exercises a component renderer. Counting effect callbacks in one and component calls in the other does not produce a meaningful speedup percentage.

Signals do not imply "no Virtual DOM"

Preact is a useful counterexample to that claim: it supports both a Virtual DOM renderer and direct signal-to-DOM bindings. In a Preact 10 app configured for JSX, these illustrative component fragments have different update paths. They are not a standalone Node script:

// Preact 10 with @preact/signals
import { signal } from '@preact/signals';

const count = signal(0);

function ReadsValue() {
  return <p>{count.value}</p>;
}

function PassesSignal() {
  return <p>{count}</p>;
}

Per the Preact Signals guide, ReadsValue subscribes the component to the signal and can render again when it changes. PassesSignal hands the signal itself to a supported text position, allowing Preact to update that text binding without rerunning the component for the signal change.

That optimization belongs to Preact's integration, not to every signal library or to arbitrary React JSX. If your view layer doesn't understand signal objects in JSX, returning the object may produce incorrect output or an error. React integrations have their own APIs and constraints.

The distinction also means that "React rerenders everything" and "signals only update one text node" are both misleading shortcuts. React can skip work; signal-backed renderers can still perform component work. Data tracking and rendering strategy are related design choices, not two mutually exclusive technologies.

Where fine-grained reactivity helps, and where it doesn't

SituationWhat to investigateLikely starting point
A form has a few local fieldsAre component updates actually expensive?Ordinary React state is usually sufficient.
Many independently changing derived valuesHow many consumers need each value?A tracked dependency graph may reduce unnecessary recomputation.
A rapidly changing text or attribute in PreactCan the signal be passed directly to a supported binding?Evaluate Preact's direct signal binding.
An expensive filter slows interactionsIs the cost in the calculation, rendering, or both?Profile and fix the expensive stage; signals don't make the algorithm cheaper.
Data comes from a shared external store in ReactAre snapshots stable and subscriptions cleaned up?Use an integration built on supported React APIs, including useSyncExternalStore where appropriate.
Async requests may finish out of orderCan an older result overwrite newer state?Handle cancellation, request identity and error states regardless of primitive.

A fine-grained graph costs something too. Tracking subscriptions, maintaining dependency edges, handling equality and scheduling effects all use memory and CPU. Batching and equality policies influence how much of that work happens. Smaller update scope is a potential advantage, not a universal performance result.

For React external stores, the useSyncExternalStore reference specifically requires a stable snapshot between store changes. A signal-like store wired into React incorrectly can produce repeated renders or inconsistent reads. Do not bypass the framework's supported integration layer to chase a supposed micro-optimization.

Measure the boundary that is slow

If you're deciding whether to change an existing app, start with a reproducible interaction, not a library benchmark headline:

  1. Identify the user-visible problem: typing delay, a slow list update, or excessive background calculations.
  2. Record React component work using React DevTools Profiler, or instrument the actual computed/effect callbacks in a signal app. Track them separately.
  3. Inspect the browser Performance panel for scripting, style/layout, painting, and long tasks. A smaller component count does not prove a faster frame.
  4. Compare the same UI behavior, data size, development/production mode, and hardware. Include startup and cleanup costs if the architecture change adds subscriptions.
  5. Confirm correctness under conditional branches, multiple writes, async completions, and unmount/disposal before attributing gains to the state model.

A good decision might be to keep React state, move a high-frequency value behind a store selector, memoize a costly calculation, use a signal-aware binding, or change the UI algorithm. Without measurements, none is automatically the winner.

What about native JavaScript Signals?

The TC39 Signals proposal is Stage 1 as of this review, not a standardized JavaScript API you can assume exists in browsers or Node. Its goal is to explore shared low-level semantics for reactive graphs across frameworks. It does not promise the same ergonomic application API as @preact/signals-core, and it deliberately leaves view scheduling and effects to integrations.

This is the architectural takeaway: signals describe dependencies between values; a framework determines how those dependencies become UI updates. Choose the model that makes your application's expensive work both observable and manageable. If the current rendering path is already cheap, changing the state primitive alone is unlikely to be the most useful optimization.