Ever found yourself staring at your React DevTools, watching countless components re-render when only a tiny piece of state changed? You're not alone. We've all been there, battling the performance dragons of unnecessary re-renders, prop drilling, and complex memoization strategies. In my last project, a real-time dashboard with constantly updating data streams, these issues became a significant bottleneck, leading to a sluggish UI and a less-than-stellar user experience. It felt like I was constantly fighting the framework, trying to optimize away renders that shouldn't have happened in the first place.
That's where Signals entered the picture. Originally popularized by frameworks like Solid.js and Preact, Signals offer a refreshing, fine-grained approach to state management that can drastically cut down on re-renders and simplify your code. While not (yet) a native part of React, the @preact/signals-react package provides a robust and performant way to bring this powerful paradigm into your existing React applications.
The Problem: React's Re-render Dilemma
React's declarative nature and Virtual DOM are fantastic for building complex UIs, but its default state management often comes with a performance cost. When you update state using useState or useReducer, React, by default, re-renders the component and all its children. This "cascade" of re-renders is efficient enough for simple applications, but in larger, data-intensive UIs, it can lead to:
- Unnecessary Computations: Components re-execute their render logic even if the data they directly depend on hasn't changed.
- Performance Bottlenecks: Excessive re-renders tax the browser's CPU and memory, resulting in a janky UI and degraded user experience, especially in deeply nested component trees.
- Prop Drilling: To share state between distant components, you often end up passing props through many intermediary components that don't actually use the data.
- Complex Optimization: Developers resort to
useMemo,useCallback, andReact.memoto manually prevent re-renders, adding boilerplate and a new layer of complexity to manage. Getting dependency arrays right can be a constant headache and a source of subtle bugs.
I distinctly remember a scenario in a previous project where a single input field update in a parent component caused an entire table of 500+ rows, each a complex component, to re-render. Even with React.memo everywhere, the overhead was significant. This is the "re-render hell" I was talking about.
The Solution: Embracing Fine-Grained Reactivity with Signals
Signals offer a different mental model, inspired by reactive programming. At their core, a signal is a simple JavaScript object that holds a value and automatically tracks who's using it. When a signal's value changes, only the components or effects that directly depend on that specific signal are updated.
Think of it like this: instead of React re-rendering a whole component tree because a state object changed, Signals provide a "surgical strike." Only the precise parts of your UI that actually display or use the changed signal value are updated.
Key Characteristics of Signals:
- Fine-Grained Reactivity: This is the biggest win. Signals ensure that updates are highly targeted, minimizing unnecessary re-renders.
- Simplified Mental Model: You read a signal's value with
.valueand update it by assigning to.value. No more intricate dependency arrays for effects or memos. - Automatic Dependency Tracking: Signals automatically track dependencies, so
effect()callbacks only re-run when their observed signals change. - Referential Stability: Signals are referentially stable across renders, similar to React's
useRef, allowing them to be safely passed down to memoized components. - Optimized Performance: Benchmarks often show Signals leading to significantly faster updates and reduced memory usage in UI-heavy applications.
Step-by-Step Guide: Integrating Preact Signals into React
Let's dive into a practical example of how to use @preact/signals-react to manage state in a React application. We'll build a simple counter and a more illustrative task list.
1. Installation
# Create a new React app (if you don't have one)
npx create-react-app my-signals-app
cd my-signals-app
# Install @preact/signals-react
npm install @preact/signals-react
# or
yarn add @preact/signals-react
This package acts as a compatibility layer, allowing you to use Preact's highly optimized Signals implementation within a React environment.
2. Basic Signal Usage: The Counter
Create a file, say src/signals.ts, to define your global signals:
<!-- src/signals.ts -->
import { signal, computed, effect } from '@preact/signals-react';
// A basic signal
export const count = signal(0);
// A computed signal: derived state based on other signals
export const doubledCount = computed(() => {
console.log('Computing doubledCount...');
return count.value * 2;
});
// An effect: runs a side effect when observed signals change
effect(() => {
console.log(`Current count is: ${count.value}`);
});
Now, let's use these signals in a React component (src/App.tsx):
<!-- src/App.tsx -->
import React from 'react';
import { count, doubledCount } from './signals';
function Counter() {
return (
<div>
<h2>Simple Counter with Signals</h2>
<p>Count: <b>{count.value}</b></p>
<p>Doubled Count: <b>{doubledCount.value}</b></p>
<button onClick={() => count.value++}>Increment Count</button>
<button onClick={() => count.value = 0}>Reset Count</button>
</div>
);
}
function OtherComponent() {
return (
<div>
<h3>Another Component</h3>
<p>I don't care about the count directly.</p>
</div>
);
}
function App() {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<Counter />
<hr />
<OtherComponent />
</div>
);
}
export default App;
3. Real-World Example: A Filterable Task List
Now for a more practical example.
<!-- src/signals.ts -->
import { signal, computed } from '@preact/signals-react';
export interface Task {
id: string;
text: string;
completed: boolean;
}
export const tasks = signal<Task[]>([]);
export const filter = signal<'all' | 'active' | 'completed'>('all');
export const filteredTasks = computed(() => {
switch (filter.value) {
case 'active':
return tasks.value.filter(task => !task.completed);
case 'completed':
return tasks.value.filter(task => task.completed);
default:
return tasks.value;
}
});
export const activeTasksCount = computed(() => {
return tasks.value.filter(task => !task.completed).length;
});
<!-- src/components/TaskInput.tsx -->
import React, { useState } from 'react';
import { tasks } from '../signals';
function TaskInput() {
const [newTaskText, setNewTaskText] = useState('');
const addTask = () => {
if (newTaskText.trim()) {
tasks.value = [
...tasks.value,
{ id: String(Date.now()), text: newTaskText.trim(), completed: false },
];
setNewTaskText('');
}
};
return (
<div>
<input
type="text"
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTask()}
placeholder="Add a new task"
/>
<button onClick={addTask}>Add Task</button>
</div>
);
}
export default TaskInput;
<!-- src/components/TaskList.tsx -->
import React from 'react';
import { filteredTasks, tasks } from '../signals';
function TaskList() {
const toggleTask = (id: string) => {
tasks.value = tasks.value.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
);
};
return (
<ul>
{filteredTasks.value.map((task) => (
<li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
{task.text}
</li>
))}
</ul>
);
}
export default TaskList;
<!-- src/components/TaskFilter.tsx -->
import React from 'react';
import { filter, activeTasksCount } from '../signals';
function TaskFilter() {
return (
<div>
<button onClick={() => filter.value = 'all'} disabled={filter.value === 'all'}>All</button>
<button onClick={() => filter.value = 'active'} disabled={filter.value === 'active'}>Active</button>
<button onClick={() => filter.value = 'completed'} disabled={filter.value === 'completed'}>Completed</button>
<p>Active tasks: {activeTasksCount.value}</p>
</div>
);
}
export default TaskFilter;
<!-- src/App.tsx (updated) -->
import React from 'react';
import TaskInput from './components/TaskInput';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
function App() {
return (
<div style={{
padding: '20px',
fontFamily: 'sans-serif',
maxWidth: '400px',
margin: 'auto',
border: '1px solid #eee',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<h1>Signal-Powered Task App</h1>
<TaskInput />
<TaskFilter />
<TaskList />
</div>
);
}
export default App;
Outcome and Takeaways
Adopting Signals in your React applications, especially with libraries like @preact/signals-react, offers compelling advantages:
- Enhanced Performance: By minimizing unnecessary re-renders, Signals significantly reduce computational overhead and improve responsiveness.
- Simplified State Logic: Automatic dependency tracking and direct value access make code cleaner and easier to maintain.
- Improved Developer Experience: Less boilerplate, fewer bugs, and more intuitive logic.
- Scalability: Fine-grained reactivity ensures smooth performance even in large apps.
Conclusion
The landscape of React state management is constantly evolving, and Signals represent a major leap forward. With primitives like signal(), computed(), and effect(), you can escape the "re-render hell" and achieve next-level performance in your apps.