Signals
Ultra-fast reactivity with automatic dependency tracking
Signals are a reactive primitive that automatically track dependencies and update only what's necessary—no Virtual DOM diffing, no unnecessary re-renders. Used by Solid.js, Preact, and Angular.
The Core Concept
// Traditional React
const [count, setCount] = useState(0);
// Component re-renders on every setState
// Signals
const count = signal(0);
// Only subscribers update, no re-render!Using @preact/signals-react
npm install @preact/signals-reactBasic Usage
// signals/counter.ts
import { signal } from '@preact/signals-react';
export const count = signal(0);
export function increment() {
count.value++;
}
export function decrement() {
count.value--;
}// components/Counter.tsx
import { count, increment, decrement } from '@/signals/counter';
export function Counter() {
// No useState, no re-renders!
// Component renders once, signal updates DOM directly
return (
<div>
<h2>Count: {count.value}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
);
}Computed Signals (Derived State)
// signals/counter.ts
import { signal, computed } from '@preact/signals-react';
export const count = signal(0);
// Automatically recomputes when count changes
export const doubleCount = computed(() => count.value * 2);
export const isEven = computed(() => count.value % 2 === 0);export function CounterDisplay() {
return (
<div>
<p>Count: {count.value}</p>
<p>Double: {doubleCount.value}</p>
<p>Is Even: {isEven.value ? 'Yes' : 'No'}</p>
</div>
);
}Effects (Side Effects)
// signals/user.ts
import { signal, effect } from '@preact/signals-react';
export const theme = signal<'light' | 'dark'>('light');
// Run when theme changes
effect(() => {
document.documentElement.classList.toggle('dark', theme.value === 'dark');
localStorage.setItem('theme', theme.value);
});
// Auto-cleanup on unmountBatch Updates
import { batch, signal } from '@preact/signals-react';
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// Without batch: fullName computes twice
firstName.value = 'Jane';
lastName.value = 'Smith';
// With batch: fullName computes once
batch(() => {
firstName.value = 'Jane';
lastName.value = 'Smith';
});Complex State Management
// signals/todos.ts
import { signal, computed } from '@preact/signals-react';
interface Todo {
id: string;
text: string;
completed: boolean;
}
export const todos = signal<Todo[]>([]);
export const filter = signal<'all' | 'active' | 'completed'>('all');
export const filteredTodos = computed(() => {
const currentTodos = todos.value;
const currentFilter = filter.value;
switch (currentFilter) {
case 'active':
return currentTodos.filter(t => !t.completed);
case 'completed':
return currentTodos.filter(t => t.completed);
default:
return currentTodos;
}
});
export const todoStats = computed(() => {
const currentTodos = todos.value;
return {
total: currentTodos.length,
active: currentTodos.filter(t => !t.completed).length,
completed: currentTodos.filter(t => t.completed).length,
};
});
// Actions
export function addTodo(text: string) {
todos.value = [
...todos.value,
{
id: crypto.randomUUID(),
text,
completed: false,
},
];
}
export function toggleTodo(id: string) {
todos.value = todos.value.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
export function deleteTodo(id: string) {
todos.value = todos.value.filter(todo => todo.id !== id);
}// components/TodoApp.tsx
import {
todos,
filter,
filteredTodos,
todoStats,
addTodo,
toggleTodo,
deleteTodo,
} from '@/signals/todos';
export function TodoApp() {
const [input, setInput] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
addTodo(input);
setInput('');
}
};
return (
<div>
<h1>Todos</h1>
{/* Stats - only updates when todoStats changes */}
<div>
Total: {todoStats.value.total} |
Active: {todoStats.value.active} |
Completed: {todoStats.value.completed}
</div>
{/* Filter */}
<div>
<button onClick={() => filter.value = 'all'}>All</button>
<button onClick={() => filter.value = 'active'}>Active</button>
<button onClick={() => filter.value = 'completed'}>Completed</button>
</div>
{/* Add Todo */}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
{/* Todo List - only updates when filteredTodos changes */}
<ul>
{filteredTodos.value.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}Async Data with Signals
// signals/user.ts
import { signal, computed } from '@preact/signals-react';
interface User {
id: string;
name: string;
email: string;
}
export const userId = signal('123');
export const userLoading = signal(false);
export const userError = signal<string | null>(null);
export const userData = signal<User | null>(null);
// Fetch user when userId changes
effect(async () => {
const id = userId.value;
userLoading.value = true;
userError.value = null;
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
userData.value = await response.json();
} catch (error) {
userError.value = error instanceof Error ? error.message : 'Unknown error';
} finally {
userLoading.value = false;
}
});// components/UserProfile.tsx
import { userData, userLoading, userError } from '@/signals/user';
export function UserProfile() {
if (userLoading.value) return <div>Loading...</div>;
if (userError.value) return <div>Error: {userError.value}</div>;
if (!userData.value) return <div>No user</div>;
return (
<div>
<h2>{userData.value.name}</h2>
<p>{userData.value.email}</p>
</div>
);
}Performance Comparison
// React (re-renders entire component)
function ReactCounter() {
const [count, setCount] = useState(0);
console.log('Component rendered'); // Logs on every setState
return <div>{count}</div>;
}
// Signals (no re-render, updates DOM directly)
const count = signal(0);
function SignalsCounter() {
console.log('Component rendered'); // Logs once!
return <div>{count.value}</div>; // Updates without re-render
}Integration with React State
// Mix signals with React state when needed
export function MixedComponent() {
// Local UI state (React)
const [isOpen, setIsOpen] = useState(false);
// Global state (Signals)
const count = useSignal(0);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle (React State)
</button>
<button onClick={() => count.value++}>
Increment (Signal)
</button>
{isOpen && <div>Count: {count.value}</div>}
</div>
);
}Debugging
// Log signal changes
effect(() => {
console.log('Count changed:', count.value);
});
// Peek without subscribing
console.log(count.peek()); // Doesn't create subscriptionBest Practices
- Use for global state: Signals excel at shared state
- Keep local state in React: Simple UI state
- Computed for derived: Don't duplicate calculations
- Batch updates: Multiple changes at once
- Effects for side effects: DOM updates, localStorage
- Peek for debugging: Avoid accidental subscriptions
- Small signals: Fine-grained reactivity
- Type signals: Full TypeScript support
- Avoid in loops: Use arrays of items
- Test independently: Signals work outside React
When to Use Signals
✅ Use signals when:
- Performance is critical (gaming, real-time)
- Lots of rapid updates
- Fine-grained subscriptions needed
- Want minimal bundle size
- Avoiding Virtual DOM overhead
❌ Don't use signals when:
- Team unfamiliar with reactive programming
- Need React-specific features (Suspense)
- Simple apps (Context is enough)
- Heavy React ecosystem dependencies
Common Pitfalls
❌ Forgetting .value: Accessing signal object
✅ Always use .value to read/write
❌ Creating signals in render: Memory leak
✅ Define signals outside components
❌ Not using computed: Recalculating manually
✅ Use computed() for derived values
❌ Too many effects: Performance hit
✅ Batch related logic in one effect
❌ Mutating signal directly: signal.value.push()
✅ Replace entire value for arrays/objects
Signals provide the fastest reactivity possible—perfect for performance-critical applications with frequent updates!