Skip to content

Preact Computed Values and Effects — Advanced Signal Patterns

DodaTech Updated 2026-06-28 6 min read

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

  1. Creating side effects inside computed: Computed signals should be pure functions. Side effects (like API calls) belong in effect(), not computed().
  2. Not disposing effects: Long-lived effects in non-component code must be disposed when no longer needed. Store the dispose function and call it.
  3. 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.
  4. Forgetting batch() for atomic updates: Without batching, intermediate states trigger effects unnecessarily, causing wasted computation or flickering UI.
  5. Using signals for everything: Simple static values don't need signals. Plain variables or useState are sufficient for data that never changes.

Practice Questions

  1. When does a computed signal re-evaluate? Answer: Only when its .value is read AND one of its dependencies has changed since the last read. This is called lazy evaluation.

  2. What does batch() do? Answer: It groups multiple signal writes into a single Transaction. Effects and computed signals update once after the batch completes.

  3. How do you clean up an effect? Answer: Return a cleanup function from the effect callback, or call the dispose function returned by effect().

  4. 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 happens if a computed signal throws an error?

: The error propagates to the code reading .value. There's no built-in error recovery. Wrap reads in try-catch if errors are expected.

Can signals cause memory leaks?

: Only if effects are not disposed. Always dispose effects when the associated component unmounts or when the effect is no longer needed.

How does signal batching differ from React's batching?

: React batches state updates in event handlers and effects. Signal batching with batch() is explicit and works anywhere, not just in event handlers.

Do signals work with Concurrent Mode or Suspense?

: Preact doesn't implement Concurrent Mode. Signals are compatible with Suspense when used correctly.

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