Preact Computed Values and Effects — Advanced Signal Patterns
In this tutorial, you will learn about Preact Computed Values and Effects. We cover key concepts, practical examples, and best practices to help you master this topic.
Learn advanced @preact/signals patterns: computed lazy evaluation, effect cleanup, batching, and integrating signals with external systems in Preact.
In this lesson, you'll explore computed signal optimization, effect lifecycle management, batch updates, and patterns for connecting signals to non-Preact code.
What You'll Learn
How computed signals handle lazy evaluation and caching, how to manage effect lifecycle with cleanup, how to batch signal updates, and how to integrate signals with external APIs.
Why It Matters
Signals provide powerful reactivity, but improper usage can cause performance issues. Understanding computed caching, effect cleanup, and batching ensures your application stays fast.
flowchart TD
A[Signal A Changes] --> B[Computed B - Dirty]
B --> C{Is B read?}
C -->|Yes| D[Recompute B]
C -->|No| E[Skip - Lazy]
D --> F[Computed C - Dirty]
F --> G{Is C read?}
G -->|Yes| H[Recompute C]
G -->|No| I[Skip - Lazy]
style B fill:#673ab8,color:#fff
style F fill:#4a148c,color:#fff
Computed Lazy Evaluation
Computed signals don't re-evaluate until their value is read:
import { signal, computed } from '@preact/signals';
const a = signal(1);
const b = signal(2);
const sum = computed(() => {
console.log('Computing sum...');
return a.value + b.value;
});
console.log('Before reading sum');
console.log('Sum:', sum.value); // Computed runs here
console.log('Sum:', sum.value); // Uses cache, doesn't re-run
a.value = 5; // Marks sum as dirty
console.log('After changing a, before reading sum');
console.log('Sum:', sum.value); // Re-computes here
Output: The computation runs only when .value is read. Reading again without changes uses the cached result. Changing a dependency marks it dirty but doesn't re-compute until the next read.
Effect Lifecycle and Cleanup
Effects can return cleanup functions that run before the next effect run or on disposal:
import { signal, effect } from '@preact/signals';
const userId = signal(null);
const dispose = effect(() => {
const id = userId.value;
if (id === null) return;
console.log('Fetching data for user:', id);
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(res => res.json())
.then(data => console.log('User data:', data))
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
// Cleanup — runs before next effect or on dispose()
return () => {
console.log('Cancelling fetch for user:', id);
controller.abort();
};
});
// Trigger effect
userId.value = 42;
// Cleanup runs, new effect starts
userId.value = 99;
// Stop the effect entirely
dispose();
Output: When userId changes, the cleanup function cancels the previous fetch before starting the new one. This prevents stale data and race conditions.
Batching Signal Updates
Batch multiple signal writes to trigger effects once:
import { signal, effect, batch } from '@preact/signals';
const firstName = signal('');
const lastName = signal('');
const email = signal('');
effect(() => {
console.log('Profile updated:', {
name: `${firstName.value} ${lastName.value}`,
email: email.value
});
});
// Without batching — effect runs 3 times
firstName.value = 'Alice'; // Effect runs
lastName.value = 'Smith'; // Effect runs
email.value = 'alice@test.com'; // Effect runs
// With batching — effect runs once
batch(() => {
firstName.value = 'Bob';
lastName.value = 'Jones';
email.value = 'bob@test.com';
});
// Effect runs once after the batch completes
Output: Without batching, the effect runs after every signal change. With batch(), all changes are applied atomically and the effect runs only once after the batch callback completes.
Connecting Signals to External Systems
Signals can bridge Preact components with non-Preact code:
import { signal, effect } from '@preact/signals';
// Module-level signal — works outside components
const connectionStatus = signal('disconnected');
// Connect to WebSocket — pure JS, no Preact needed
function connectWebSocket(url) {
const ws = new WebSocket(url);
ws.onopen = () => connectionStatus.value = 'connected';
ws.onclose = () => connectionStatus.value = 'disconnected';
ws.onerror = () => connectionStatus.value = 'error';
// Effect to log status changes
const dispose = effect(() => {
console.log(`WebSocket status: ${connectionStatus.value}`);
});
return () => {
ws.close();
dispose();
};
}
// Preact component reads the signal
function StatusBar() {
return (
<div class={`status-${connectionStatus.value}`}>
Connection: {connectionStatus.value}
</div>
);
}
Output: The WebSocket connection status updates a signal. The Preact component reacts to changes automatically, and the effect logs every status transition.
Signal Composition
Combine multiple signals into derived state:
import { signal, computed } from '@preact/signals';
const searchQuery = signal('');
const items = signal(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']);
const selectedCategory = signal('all');
const filteredItems = computed(() => {
const query = searchQuery.value.toLowerCase();
const category = selectedCategory.value;
return items.value.filter(item => {
const matchesQuery = item.toLowerCase().includes(query);
const matchesCategory = category === 'all' || item.startsWith(category);
return matchesQuery && matchesCategory;
});
});
const resultCount = computed(() => filteredItems.value.length);
function SearchUI() {
return (
<div>
<input type="text" onInput={(e) => searchQuery.value = e.target.value} />
<select onChange={(e) => selectedCategory.value = e.target.value}>
<option value="all">All</option>
<option value="A">A-E</option>
<option value="F">F-J</option>
</select>
<p>{resultCount.value} results</p>
<ul>
{filteredItems.value.map(item => <li>{item}</li>)}
</ul>
</div>
);
}
Output: The search filters in real-time as the user types. The resultCount computed signal depends on filteredItems, creating a reactive chain.
Common Mistakes
- Creating side effects inside
computed: Computed signals should be pure functions. Side effects (like API calls) belong ineffect(), notcomputed(). - Not disposing effects: Long-lived effects in non-component code must be disposed when no longer needed. Store the dispose function and call it.
- Reading signals inside effects that change the same signals: This creates infinite loops. Don't write to a signal inside an effect that reads it.
- Forgetting
batch()for atomic updates: Without batching, intermediate states trigger effects unnecessarily, causing wasted computation or flickering UI. - Using signals for everything: Simple static values don't need signals. Plain variables or
useStateare sufficient for data that never changes.
Practice Questions
When does a computed signal re-evaluate? Answer: Only when its
.valueis read AND one of its dependencies has changed since the last read. This is called lazy evaluation.What does
batch()do? Answer: It groups multiple signal writes into a single Transaction. Effects and computed signals update once after the batch completes.How do you clean up an effect? Answer: Return a cleanup function from the effect callback, or call the dispose function returned by
effect().Can you use signals outside of Preact components? Answer: Yes. Signals are framework-agnostic and work in plain JavaScript, Node.js, or any other context.
Challenge
Build a reactive form validation system using signals. Each field is a signal, and computed signals derive validation errors. Use batch() to update multiple fields atomically when loading saved data.
Mini Project
Create a real-time collaborative todo list: todos are signal arrays, changes sync via WebSocket (external system integration), and computed signals derive stats (completed count, overdue items).
FAQ
What's Next
Learn about Preact Context to understand how to share state across the component tree without prop drilling.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro