Preact Signals in React
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
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:
- During the initial React render, it creates a text node with the signal's current value
- It subscribes to the signal directly from the DOM text node
- When the signal changes, it updates
textNode.datadirectly -- 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.
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
createElementobjects, no diff, no patch. JusttextNode.data = "10001". - No prop comparison overhead: No
React.memoshallow 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.
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.
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
| Scenario | Recommendation | Why |
|---|---|---|
| High-frequency updates (tickers, cursors, timers) | Consider signals | Avoids re-render overhead on rapid updates where concurrent features are not needed |
| Shared global state across many components | Consider signals | Avoids Context re-render cascades without Provider nesting |
| Forms with Suspense boundaries | Avoid signals | Form state needs to coordinate with Suspense for loading states |
| Data fetching with transitions | Avoid signals | Transitions require React to control when updates commit |
| Apps using React Server Components | Avoid signals | RSC assumes React controls the render pipeline end-to-end |
| Brownfield migration | Use cautiously | Signals 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.
- 1Preact Signals in React update DOM text nodes directly, bypassing React's reconciliation
- 2This gives real performance wins for frequent, localized updates but breaks concurrent features (Transitions, Suspense, time-slicing)
- 3Never mix React state and signals in the same component -- it creates inconsistent update timing
- 4React DevTools cannot see signal state or track signal-driven updates
- 5Use signals for performance-critical subtrees that do not need concurrent React features
| What developers do | What 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 |