Why Signals Redefine React State Management
You have likely heard the buzz surrounding Signals lately. As modern frontend architecture evolves, Signals are appearing in almost every major framework, and they are now making significant inroads into the React ecosystem. But what exactly are they, why are they gaining such immense traction, and how can you implement them in your React applications?
The Problem with State Management
Every modern application must react to change. Whether a user clicks a button, fetches data, or navigates a route, the UI must stay in sync. In React, we manage this "state" using hooks like useState and useReducer.
While powerful and reliable, managing state reliably across complex applications has historically posed significant architectural challenges.
Even for experienced developers, it is easy to:
- Miss a dependency in useMemo or useCallback and cause stale data or difficult-to-debug performance regressions.
- Change one piece of state that could trigger a component re-render, which can force its entire child tree to re-render as well.
- Business logic often ends up tangled with component code.
- As applications grow, orchestrating reactivity across deeply nested components becomes a challenge and requires complex patterns
Additionally, as the application grow, orchestrating reactivity across deeply nested components becomes a challenge and requires global state and to manage complex patterns that are difficult to maintain.
What are Signals?
Think of a Signal as a highly efficient reactive primitive that contains a value. Unlike React state, which is tightly bound to the component's render cycle, signals exist independently of the component lifecycle.
Signals track dependencies at a granular level. When a value changes, only the specific parts of the UI that depend on that value update. This is the primary advantage: signals bypass the traditional "top-down" re-render pattern.
Why Signals Are Gaining Momentum
The frontend community is shifting toward signals (already adopted by SolidJS, Vue, and Angular) because they decouple data from the component tree. A TC39 proposal even aims to make signals a native part of the JavaScript language.
Signals provide a "sweet spot" between simplicity and performance. They remove the ceremony of heavy libraries (like Redux's actions, reducers, and selectors) while providing predictable, local updates.
Key advantages include:
- Decoupled Logic: You can move business logic entirely outside of React components.
- Simplified Global State: Instead of prop drilling through ten layers, any component can subscribe to a centralized Signal.
- Automatic Dependency Tracking: Signals know exactly what they depend on. There is no need to manually manage dependency arrays or memoize callbacks.
- Derived State: You can easily create new signals that depend on existing ones; when the source changes, the derivative updates automatically.
Core Concepts and Syntax
Using the @preact/signals-react library, we can implement signals with minimal boilerplate.
A Signal holds the state, while a Computed Signal derives new values from other signals.
import { signal } from '@preact/signals-react';
// Create a signal with an initial value
const counter = signal(10);
console.log(counter.value); // 10
// How to update the value
counter.value = 20;
console.log(counter.value); // 20
// Derrived state with computed: 'doubleCount' updates only when 'count' changes
const doubleCount = computed(() => counter.value * 2);
console.log(doubleCount.value); //40
Side Effects and Batching
The effect function tracks signals used inside it and re-runs whenever they update. To prevent multiple updates from triggering multiple effects, use batch.
import { signal, effect, batch } from '@preact/signals-react';
const firstName = signal("Jane");
const lastName = signal("Doe");
// Runs every time firstName or lastName changes
effect(() => {
console.log(`User: ${firstName.value} ${lastName.value}`);
return () => { /* cleanup if needed */ };
});
const updateName = () => {
// Batching ensures the effect only runs once after both values change
batch(() => {
firstName.value = "John";
lastName.value = "Smith";
});
};
updateName();
Implementing Signals in React
Signals are available via libraries such as @preact/signals-react and @preact/signals-core. The Preact implementation works well with React.
Installation
npm install @preact/signals-react
To use signals efficiently in React, you have three primary strategies.
Method 1: The Babel Transform (Recommended)
The most efficient way to use signals is via a Babel transform. This automatically makes your components reactive when they access a signal's value.
First, install the transformer:
npm install --save-dev @preact/signals-react-transform
Then, update your Babel configuration:
{
"plugins": [["module:@preact/signals-react-transform"]]
}
Now, you can use them in React
import { signal, useSignal } from '@preact/signals-react';
// you can define it outside of the React scope
const counter = signal(0);
export function Counter() {
// or inside the component
const total = useSignal(0);
// 2. Accessing .value automatically subscribes this component to changes
const increment = () => {
// you can easily update the value
count.value += 1;
};
return (
<div>
<h1>Count: {count.value}</h1> // updating counter will update h1
<button onClick={increment}>Increment</button>
</div>
);
}
Method 2: useSignals hook
If you can't use the Babel transform plugin, you can directly call the useSignals hook at the top of your component to make your components reactive.
import { signal, useSignal} from '@preact/signals-react';
import { useSignals } from "@preact/signals-react/runtime";
export function Counter() {
useSignals(); // Subscribes the component to any signal accessed in the body
// You can also define signals inside the component using useSignal
const counter = useSignal(0);
const increment = () => {
count.value += 1;
};
return (
<div>
<h1>Count: {count.value}</h1> // updating counter will update h1
<button onClick={increment}>Increment</button>
</div>
);
}
Method 3: The Custom Hook (Standard React Approach)
For library authors or if you are looking for maximum compatibility with standard React patterns, you can use useSyncExternalStore. This is the "vanilla" way to bridge external state into React's rendering engine.
// Now you can directly use signals-core package instead of signals-react
import { signal, type Signal } from '@preact/signals-core';
import { useSyncExternalStore } from 'react';
// A custom hook to bridge Signals to React
export function useSignalValue<T>(sig: Signal<T>): T {
return useSyncExternalStore(
sig.subscribe.bind(sig),
sig.peek.bind(sig)
);
}
// Counter component
const globalCounter = signal(0);
const increment = () => {
globalCounter.value += 1;
};
export function Counter() {
const count = useSignalValue(globalCounter);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
</div>
);
}
Handling Async Operations
While signals are synchronous by nature. Promises, however, resolve at an unknown time in the future. To use them with async code, you can create a small utility function for fetching data and exposing data, isLoading, and error as signals.
import { signal, effect, batch } from '@preact/signals-react';
interface ResourceState<T, T_Error> {
data: T | undefined;
isLoading: boolean;
error: T_Error;
}
export function resource<T, T_Error = unknown>(
promiseFunction: (abortSignal?: AbortSignal) => Promise<T>
): ResourceState<T, T_Error> {
const data = signal<T | undefined>(undefined);
const isLoading = signal<boolean>(true);
const error = signal<T_Error | undefined>(undefined);
const dispose = effect(() => {
let isAborted = false;
// Create abort controller
const abortController = new AbortController();
const promise = promiseFunction(abortController.signal);
promise
.then((result) => {
if (isAborted) return;
batch(() => {
data.value = result;
isLoading.value = false;
error.value = undefined;
});
})
.catch((errorMessage) => {
if (isAborted) return;
batch(() => {
data.value = undefined;
isLoading.value = false;
error.value = err as T_Error;
});
});
return () => {
abortController.abort();
isAborted = true;
batch(() => {
isLoading.value = true;
error.value = undefined;
});
};
});
// Return object with data, loading state, error, and dispose function
return {
data,
isLoading,
error,
dispose,
};
}
How to Test Signals
Testing signals is significantly simpler than testing React state because signals are plain JavaScript objects. You do not need complex rendering utilities or component wrappers to verify your logic.
import { expect, test } from 'vitest';
import { signal, computed } from '@preact/signals-react';
test('computed signal updates correctly', () => {
const count = signal(1);
const double = computed(() => count.value * 2);
expect(double.value).toBe(2);
// Update the source signal
count.value = 5;
// Assert the derived value updated
expect(double.value).toBe(10);
});
Final Thoughts
Signals don't replace React state; they augment it. You can still use useState for simple, component-local UI state (like a toggle). Use Signals for shared state, complex business logic, and performance-critical updates. For many projects, signals offer a practical, performant alternative to heavier state libraries.
If you want to try them, start small. Replace a single shared piece of state with a Signal, observe the reactivity, and expand only where it adds value. However, maintain architectural discipline. Managing async operations, side effects, and derived state requires care; without documented patterns, mixing these paradigms can lead to a fragmented and confusing codebase.
Last updated on Wed Apr 29 2026