Signals are a reactive primitive that represents a value that changes over time and can be observed for changes. They are becoming the foundation for state management in modern JavaScript frameworks. This comprehensive guide covers everything you need to know about Signals.
What are Signals?
A Signal is an object that holds a value and allows you to observe changes to that value. When the value changes, all dependent computations automatically re-run.
// Basic signal concept
import { signal, computed, effect } from '@preact/signals';
const count = signal(0);
// Computed value depends on signal
const doubled = computed(() => count.value * 2);
// Effect runs when signal changes
effect(() => {
console.log('Count is now:', count.value);
});
// Update the signal
count.value = 5; // Triggers: effect runs, doubled recalculates
Why Signals?
Traditional reactivity systems like those in React require re-rendering components to detect changes. Signals provide fine-grained reactivity - only the parts of the UI that depend on changed data update.
flowchart TD
subgraph Traditional["React useState"]
T1[State Change] --> T2[Component Re-render]
T2 --> T3[Virtual DOM Diff]
T3 --> T4[Update DOM]
end
subgraph Signal["Signal-based"]
S1[Signal Change] --> S2[Direct DOM Update]
S2 --> S3[Only affected nodes]
end
T1 -.->|Slower| S1
Core Concepts
Creating Signals
// Preact Signals
import { signal, computed, effect } from '@preact/signals';
const name = signal('John');
const age = signal(30);
const isActive = signal(true);
// With initial value
const items = signal(['item1', 'item2']);
Reading and Writing
// Reading value
console.log(name.value);
// Writing value
name.value = 'Jane';
// With function update
count.value = count.value + 1;
// Or using .modify (for objects/arrays)
items.value = [...items.value, 'new item'];
Computed Values
import { signal, computed } from '@preact/signals';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed automatically tracks dependencies
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
console.log(fullName.value); // "John Doe"
// Changing either signal updates computed
firstName.value = 'Jane';
console.log(fullName.value); // "Jane Doe"
Effects
import { signal, effect } from '@preact/signals';
const count = signal(0);
// Effect runs immediately and on every change
const cleanup = effect(() => {
console.log(`Count changed to: ${count.value}`);
// Optional: return cleanup function
return () => {
console.log('Effect cleaned up');
};
});
// Update triggers effect
count.value = 1; // Logs: "Count changed to: 1"
count.value = 2; // Logs: "Count changed to: 2"
Framework Implementations
Preact Signals
Preact Signals provides a high-performance reactivity system for Preact.
npm install @preact/signals
import { render } from 'preact';
import { signal, computed, effect } from '@preact/signals';
const count = signal(0);
const isEven = computed(() => count.value % 2 === 0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<p>Is Even: {isEven}</p>
<button onClick={() => count.value++}>
Increment
</button>
</div>
);
}
render(<Counter />, document.getElementById('app'));
// Global effect
effect(() => {
console.log('Global count:', count.value);
});
Integration with Preact Hooks
import { useSignal, useComputed, useSignalEffect } from '@preact/signals';
function UseSignalExample() {
const count = useSignal(0);
const doubled = useComputed(() => count.value * 2);
useSignalEffect(() => {
console.log('Count changed:', count.value);
});
return (
<div>
<p>{count}</p>
<p>Doubled: {doubled}</p>
<button onClick={() => count.value++}>
+1
</button>
</div>
);
}
SolidJS Signals
SolidJS uses signals as its core reactivity system.
npm install solid-js
import { createSignal, createMemo, createEffect, onCleanup } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
// createMemo for computed values
const doubled = createMemo(() => count() * 2);
// createEffect for side effects
createEffect(() => {
console.log('Count is now:', count());
// Cleanup on unmount
onCleanup(() => console.log('Cleanup'));
});
return (
<div>
<p>Count: {count()}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
);
}
SolidJS Store
import { createStore } from 'solid-js/store';
function StoreExample() {
const [state, setState] = createStore({
user: {
name: 'John',
settings: { theme: 'dark' }
},
items: ['a', 'b', 'c']
});
return (
<div>
{/* Nested reactivity */}
<p>{state.user.name}</p>
<button onClick={() => setState('user', 'name', 'Jane')}>
Update Name
</button>
{/* Array operations */}
<button onClick={() => setState('items', (items) => [...items, 'd'])}>
Add Item
</button>
</div>
);
}
Angular Signals
Angular Signals are integrated into Angular’s change detection.
# Angular 16+ includes signals
npm install @angular/core@16
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">Increment</button>
`
})
export class CounterComponent {
// Writable signal
count = signal(0);
// Computed signal
doubled = computed(() => this.count() * 2);
constructor() {
// Effect (runs on changes)
effect(() => {
console.log('Count changed:', this.count());
});
}
increment() {
this.count.update(c => c + 1);
}
}
Angular Signal Updates
import { signal, computed } from '@angular/core';
@Component({...})
export class ExampleComponent {
// Basic signal
value = signal(10);
// Update with new value
updateValue() {
this.value.set(20);
}
// Update with function
incrementValue() {
this.value.update(v => v + 1);
}
// Mutate (for objects/arrays)
data = signal<{items: string[]}>({ items: ['a'] });
mutateData() {
this.data.update(d => ({ items: [...d.items, 'b'] }));
// Or use mutate for mutable updates
this.data.mutate(d => d.items.push('c'));
}
}
Standalone Signal Libraries
Synced Store
npm install @syncedstore/core @syncedstore/core
import { store, deepify } from '@syncedstore/core';
import { YjsPersistor } from '@syncedstore/core/persistence/yjs';
// Create store
const store = deepify({});
// Use anywhere - fully reactive
const $ = store.todo;
// In any component
function Todo() {
return (
<div>
{$.tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
<button onClick={() => $.tasks.push({ id: Date.now(), title: 'New' })}>
Add Task
</button>
</div>
);
}
Building Custom Signal Implementation
Basic Signal
type Listener<T> = (value: T) => void;
class Signal<T> {
private value: T;
private listeners: Set<Listener<T>> = new Set();
constructor(initialValue: T) {
this.value = initialValue;
}
get(): T {
return this.value;
}
set(newValue: T): void {
if (this.value !== newValue) {
this.value = newValue;
this.notify();
}
}
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach(listener => listener(this.value));
}
}
// Usage
const count = new Signal(0);
count.subscribe(value => console.log('Count:', value));
count.set(5); // Logs: Count: 5
Computed Signal
class Computed<T> {
private value: T;
private computed: () => T;
private signals: Signal<any>[] = [];
constructor(compute: () => T) {
this.computed = compute;
this.value = compute();
this.trackDependencies();
}
get(): T {
return this.value;
}
private trackDependencies() {
// Simplified - would need actual dependency tracking
this.value = this.computed();
}
}
Effect System
function effect(fn: () => void | (() => void)) {
let cleanup: (() => void) | undefined;
const run = () => {
// Cleanup previous run
if (cleanup) cleanup();
// Run effect
cleanup = fn();
};
run();
return () => {
if (cleanup) cleanup();
};
}
// Usage
const count = new Signal(0);
const stop = effect(() => {
console.log('Effect:', count.get());
});
count.set(1); // Logs: Effect: 1
count.set(2); // Logs: Effect: 2
stop(); // Stop the effect
Reactivity Patterns
Derived State
import { signal, computed } from '@preact/signals';
const numbers = signal([1, 2, 3, 4, 5]);
// Computed automatically derives from source
const sum = computed(() =>
numbers.value.reduce((a, b) => a + b, 0)
);
const average = computed(() =>
sum.value / numbers.value.length
);
const max = computed(() =>
Math.max(...numbers.value)
);
// Update source, all computed update
numbers.value = [10, 20, 30];
console.log(sum.value); // 60
console.log(average.value); // 20
console.log(max.value); // 30
Async Signals
import { signal } from '@preact/signals';
const createAsyncSignal = <T>(promise: Promise<T>) => {
const state = signal<'pending' | 'fulfilled' | 'rejected'>('pending');
const data = signal<T | null>(null);
const error = signal<Error | null>(null);
promise
.then(value => {
state.value = 'fulfilled';
data.value = value;
})
.catch(err => {
state.value = 'rejected';
error.value = err;
});
return { state, data, error };
};
// Usage
const userData = createAsyncSignal(fetchUser(123));
Signal Batching
import { batch } from '@preact/signals';
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// Batch updates - only triggers once
batch(() => {
firstName.value = 'Jane';
lastName.value = 'Smith';
});
// Effect only runs once with "Jane Smith"
effect(() => {
console.log('Name:', fullName.value);
});
Conditional Reactivity
import { signal, computed } from '@preact/signals';
const user = signal<{ name: string; plan: string } | null>({
name: 'John',
plan: 'pro'
});
const displayName = computed(() => {
if (!user.value) return 'Guest';
return user.value.plan === 'pro'
? `โญ ${user.value.name}`
: user.value.name;
});
// Returns "โญ John"
Comparison with Other Approaches
Signals vs useState (React)
// React useState - re-renders entire component
const [count, setCount] = useState(0);
const doubled = count * 2; // Recalculates every render
return (
<div>
<p>{count}</p>
<p>{doubled}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
// Preact Signals - fine-grained updates
const count = signal(0);
const doubled = computed(() => count.value * 2);
return (
<div>
<p>{count}</p> {/* Only this updates */}
<p>{doubled}</p>
<button onClick={() => count.value++}>+</button>
</div>
);
Signals vs Redux
// Redux - centralized store
const store = createStore(counterReducer);
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
{count}
</button>
);
}
// Signals - distributed state
const count = signal(0);
function Counter() {
return (
<button onClick={() => count.value++}>
{count}
</button>
);
}
Signals vs Proxy (Vue)
// Vue Reactivity (uses Proxies)
const state = reactive({
count: 0
});
return (
<button onClick={() => state.count++}>
{state.count}
</button>
);
// Signals (explicit)
const count = signal(0);
return (
<button onClick={() => count.value++}>
{count}
</button>
);
Performance Benefits
flowchart LR
subgraph Metrics["Performance Metrics"]
Time["Time to Interactive"]
Memory["Memory Usage"]
Updates["Update Frequency"]
end
React[("React useState")] --> R1[~150ms]
React --> R2[Higher]
React --> R3[All components]
Signals[("Signal-based")] --> S1[~20ms]
Signals --> S2[Lower]
Signals --> S3["Only changed"]
end
Benchmark Results
| Framework | 10K Updates | Memory | Bundle Size |
|---|---|---|---|
| React useState | ~450ms | High | 40KB |
| Preact Signals | ~15ms | Low | 14KB |
| SolidJS | ~8ms | Very Low | 7KB |
| Vue 3 | ~20ms | Low | 90KB |
Best Practices
Do: Keep Signals Local
// Good: Local signal in component
function Counter() {
const count = signal(0);
return (
<button onClick={() => count.value++}>
{count}
</button>
);
}
// Avoid: Global signal when not needed
const globalCount = signal(0); // Only if truly global
Do: Use Computed for Derived Values
// Good: Computed automatically updates
const price = signal(10);
const quantity = signal(2);
const total = computed(() => price.value * quantity.value);
// Avoid: Manual calculation
const totalManual = () => price.value * quantity.value;
Don’t: Over-use Effects
// Bad: Effect for derived values
const doubled = computed(() => count.value * 2);
effect(() => {
console.log(doubled.value); // Use computed directly
});
// Good: Effect for side effects
effect(() => {
document.title = `Count: ${count.value}`;
});
Do: Use Batching
import { batch } from '@preact/signals';
batch(() => {
user.value.name = 'Jane';
user.value.email = '[email protected]';
preferences.value.theme = 'dark';
}); // Single update, not three
Integration Examples
With TypeScript
import { signal, computed } from '@preact/signals';
interface User {
id: number;
name: string;
email: string;
}
// Typed signal
const user = signal<User | null>(null);
// Typed computed
const userName = computed<string>(() =>
user.value?.name ?? 'Guest'
);
// Function update with types
const updateUser = (name: string) => {
if (user.value) {
user.value = { ...user.value, name };
}
};
With Web Components
import { signal, effect } from '@preact/signals';
class CounterElement extends HTMLElement {
count = signal(0);
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
effect(() => {
this.shadowRoot.innerHTML = `
<button>Count: ${this.count}</button>
`;
this.shadowRoot.querySelector('button')
.addEventListener('click', () => this.count.value++);
});
}
}
customElements.define('counter-element', CounterElement);
With Server State
import { signal } from '@preact/signals';
const createResource = <T>(fetcher: () => Promise<T>) => {
const data = signal<T | null>(null);
const error = signal<Error | null>(null);
const loading = signal(true);
fetcher()
.then(result => {
data.value = result;
})
.catch(err => {
error.value = err;
})
.finally(() => {
loading.value = false;
});
return { data, error, loading };
};
// Usage
const userResource = createResource(() => fetchUser(123));
External Resources
- Preact Signals Documentation
- SolidJS Reactivity
- Angular Signals
- Signals TC39 Proposal
- Kent C. Dodds: Signals Blog Posts
Conclusion
Signals represent the evolution of JavaScript reactivity. By providing fine-grained updates without the overhead of virtual DOM diffing or component re-rendering, signals offer significant performance improvements for complex applications.
Key takeaways:
- Signals provide O(1) updates vs O(n) component re-renders
- They work across multiple frameworks (Preact, Solid, Angular)
- The TC39 proposal suggests signals may become a native JavaScript feature
- They simplify state management by eliminating boilerplate
Whether you’re building a new application or optimizing an existing one, signals provide a powerful primitive for managing reactive state.
Comments