Skip to content

Preact Signals in React

expert17 min read

Signals Inside React: The Controversial Experiment

What if you could use signals in React without leaving React? That's exactly what @preact/signals-react does. It lets you create signals that bypass React's re-render model entirely, updating DOM text nodes directly even though you're writing React components.

import { signal, computed } from '@preact/signals-react';

const count = signal(0);
const doubled = computed(() => count.value * 2);

function Counter() {
  return (
    <div>
      <button onClick={() => count.value++}>
        Count: {count}
      </button>
      <p>Doubled: {doubled}</p>
    </div>
  );
}

Notice something? No useState. No useMemo. No React.memo. And yet, when count.value++ fires, only the text nodes showing the count and doubled values update. The Counter function does not re-execute. The virtual DOM does not diff. React doesn't even know something changed.

The Mental Model

Mental Model

Imagine React is a restaurant manager who takes orders, goes to the kitchen (component function), gets the full meal (virtual DOM), compares it to what's on the table, and replaces changed dishes. Preact Signals is a waiter who memorized where each dish goes and walks straight to the table to swap out just the one dish that changed, completely bypassing the manager. The manager doesn't know the dish changed. The customer (user) sees the correct food. But the restaurant's official process was skipped.

This works great for performance. But if the manager needs to coordinate something (like serving all courses in order, or pausing a meal for an allergy check), the rogue waiter can cause problems. That's the concurrent features trade-off.

How It Works: Bypassing React's Render Cycle

When you place a signal directly in JSX ({count} not {count.value}), the Preact Signals React integration does something clever:

  1. During the initial React render, it creates a text node with the signal's current value
  2. It subscribes to the signal directly from the DOM text node
  3. When the signal changes, it updates textNode.data directly -- no React involvement
// Simplified: what happens when you write {count} in JSX
// The signal integration replaces the signal reference with a component
// that subscribes to the signal and updates a text node
function SignalText({ signal }) {
  const textRef = useRef(null);

  useEffect(() => {
    const unsubscribe = signal.subscribe(value => {
      if (textRef.current) {
        textRef.current.data = String(value);
      }
    });
    return unsubscribe;
  }, [signal]);

  return <span ref={textRef}>{signal.value}</span>;
}

The actual implementation is more sophisticated (it uses a Babel transform via @preact/signals-react-transform to automatically wrap component functions), but the concept is the same: subscribe to signals at the DOM level, bypass React's reconciler.

The Babel Transform

For signals to work transparently in React components, you install the Babel plugin:

// babel.config.js
module.exports = {
  plugins: [
    ['module:@preact/signals-react-transform']
  ]
};

This transform wraps your component functions so that signal reads inside JSX automatically subscribe to updates. Without the transform, you need to manually call useSignals() at the top of each component.

Quiz
How does @preact/signals-react update the DOM when a signal changes?

Performance: The Numbers

The performance advantage is real and measurable. In scenarios with frequent, localized state updates:

  • No component re-execution: The Counter function above runs once. Period. Even with 10,000 clicks.
  • No VDOM allocation: No createElement objects, no diff, no patch. Just textNode.data = "10001".
  • No prop comparison overhead: No React.memo shallow comparison on every parent render.
  • Shared state without context re-renders: Multiple components can read the same signal without a Context provider that re-renders all consumers.
// The "many counters" benchmark
const counters = Array.from({ length: 1000 }, () => signal(0));

function App() {
  return (
    <div>
      {counters.map((counter, i) => (
        <CounterDisplay key={i} counter={counter} />
      ))}
      <button onClick={() => counters[0].value++}>
        Increment first
      </button>
    </div>
  );
}

function CounterDisplay({ counter }) {
  return <span>{counter}</span>;
}

Clicking the button updates only one text node out of 1000. In React with useState, even with perfect memo optimization, the parent's re-render triggers prop comparison for all 1000 children. With signals, the other 999 components are not involved at all.

Where the performance edge disappears

Signals don't help with:

  • Initial render: React still mounts all components normally. Signals only help with updates.
  • Structural changes: Adding/removing components requires React's reconciler.
  • Server rendering: Signals are a client-side concept. SSR still uses React's normal rendering.

The Controversy: Breaking React's Rules

The React team has been clear: Preact Signals in React "breaks React's rules." Here's why this matters.

React assumes it controls the DOM

React's reconciler assumes that between renders, the DOM matches what React last committed. When signals update text nodes behind React's back, React's internal representation of the DOM is wrong. If React does a re-render later (triggered by something else), it might try to update a text node that signals already changed, potentially causing flicker or inconsistencies.

Concurrent features can't coordinate

React's concurrent mode (Transitions, Suspense, time-slicing) works by controlling when updates commit to the DOM. React can prepare a new UI in memory without showing it, then reveal it all at once. Signals bypass this entirely -- they commit immediately to the DOM, ignoring any pending transitions or suspended boundaries.

function SearchResults() {
  const [query, setQuery] = useState('');
  const results = signal([]);  // Mixing React state and signals

  return (
    <>
      <input
        value={query}
        onChange={e => {
          startTransition(() => setQuery(e.target.value));  // React: defer this
          fetchResults(e.target.value).then(r => {
            results.value = r;  // Signal: update immediately!
          });
        }}
      />
      <ResultList results={results} />
    </>
  );
}

The startTransition tells React to defer the UI update. But the signal updates immediately. The user sees results changing while the query input is still pending. This breaks the coordinated update guarantee that transitions provide.

Common Trap

Mixing React state (useState, useReducer) with Preact Signals in the same component is the most common source of bugs. React's state triggers re-renders; signals bypass them. When both update simultaneously, the component can show an inconsistent state. The safest approach: go all-in on signals for a subtree, or don't use them at all. Half-measures are where the dragons live.

Quiz
What is the primary concern with using Preact Signals in React?

DevTools Compatibility

React DevTools inspects React's component tree, props, and state. Signals are invisible to it:

  • Signal values don't appear in the component's state panel
  • Signal changes don't trigger the "highlight updates" feature
  • The profiler doesn't capture signal-driven updates (since no React render occurs)

This makes debugging harder. If a text node shows the wrong value, you can't use React DevTools to trace why. You need the separate Preact Signals DevTools extension, which creates a parallel debugging experience.

When To Use Preact Signals in React

ScenarioRecommendationWhy
High-frequency updates (tickers, cursors, timers)Consider signalsAvoids re-render overhead on rapid updates where concurrent features are not needed
Shared global state across many componentsConsider signalsAvoids Context re-render cascades without Provider nesting
Forms with Suspense boundariesAvoid signalsForm state needs to coordinate with Suspense for loading states
Data fetching with transitionsAvoid signalsTransitions require React to control when updates commit
Apps using React Server ComponentsAvoid signalsRSC assumes React controls the render pipeline end-to-end
Brownfield migrationUse cautiouslySignals can optimize hot paths without rewriting entire components

The pragmatic approach

If your app doesn't use concurrent features, Suspense for data, or React Server Components, Preact Signals is a legitimate performance optimization. Many production apps don't use these features. For those apps, signals give you fine-grained updates with zero API surface change.

But if you're building with Next.js App Router, using Suspense boundaries, or relying on startTransition for navigation, signals fight against React's architecture. You're better off with React's native optimization tools (Compiler, useMemo, useCallback) or evaluating whether Solid or another signal-native framework would be a better fit.

The future: could React adopt signals natively?

The React team has explored signal-like primitives internally. The React Compiler (React Forget) is their answer: instead of adding signals, they want to automatically optimize the existing model through compile-time analysis.

The Compiler identifies which values are stable between renders and which are derived, then inserts memoization automatically. The goal is signal-like performance without signal-like APIs. If the Compiler ships fully, the performance gap between React and signal-based frameworks narrows significantly.

However, the Compiler can only optimize React's model, not eliminate it. Components still re-execute (though less often). The virtual DOM still exists (though with fewer nodes to diff). The fundamental architectural difference remains: React's model is "recompute and diff," signals' model is "subscribe and mutate." The Compiler makes React's model much more efficient, but it doesn't change the model itself.

Key Rules
  1. 1Preact Signals in React update DOM text nodes directly, bypassing React's reconciliation
  2. 2This gives real performance wins for frequent, localized updates but breaks concurrent features (Transitions, Suspense, time-slicing)
  3. 3Never mix React state and signals in the same component -- it creates inconsistent update timing
  4. 4React DevTools cannot see signal state or track signal-driven updates
  5. 5Use signals for performance-critical subtrees that do not need concurrent React features
What developers doWhat they should do
Using signals for all state in a React app that uses Suspense
Suspense and transitions require React to control when updates commit. Signals bypass this control, causing loading state inconsistencies
Only use signals for state that does not participate in Suspense boundaries or transitions
Forgetting the Babel transform and wondering why signals do not auto-track
Without the transform, component functions are not wrapped in a tracking context. Signal reads in JSX are not automatically subscribed
Install @preact/signals-react-transform or call useSignals() at the top of each component
Passing signal.value as props instead of the signal itself
signal.value reads the current value and loses reactivity. Passing the signal object allows child components to subscribe directly
Pass the signal object to preserve reactivity, or use signal.value when you intentionally want a snapshot