Effects

Run side effects in response to signal changes. Effects handle everything that isn't just returning UI.

What Are Effects?

Effects are functions that run automatically when their dependencies change. They're for side effects: things like:

  • Fetching data
  • Setting up subscriptions
  • Updating the document title
  • Logging
  • Syncing with external systems
import { signal, effect } from 'what-framework';

const count = signal(0);

// Runs immediately, then re-runs when count changes
effect(() => {
  console.log('Count is now:', count());
});

count.set(5);  // Logs "Count is now: 5" on the next microtask

How Effects Work

When you create an effect:

  1. The function runs immediately
  2. What tracks which signals are read during execution
  3. When any of those signals change, the effect is queued and re-runs on the next microtask
const a = signal(1);
const b = signal(2);

effect(() => {
  console.log(a() + b());  // Tracks both a and b
});

a.set(10);
b.set(20);
// Nothing logged yet. One re-run on the next microtask, logging 30

When effects run

Only the very first run is synchronous. After that a write queues the effect and the queue is drained on the next microtask, so several writes in the same tick produce a single re-run with the final values. Wrap the writes in batch() to have that run happen before the block returns, or call flushSync() to drain the queue on the spot. Both are exported from what-framework.

Do not build anything on top of the exact timing. What promotes some single-dependency effects to a faster inline path once their dependency set is proven stable, so an effect that ran on a microtask earlier may run synchronously later. In a test, always await a microtask or call flushSync() before asserting.

No dependency arrays

Unlike React's useEffect(fn, [deps]), What effects auto-track. Every signal read inside the function becomes a dependency automatically. You never need to maintain a deps array, and you'll never have stale closure bugs.

If you only need code to run once on mount (like React's useEffect(fn, [])), use onMount instead.

Cleanup Functions

Effects can return a cleanup function that runs before the next execution or when the effect is disposed:

effect(() => {
  const handler = () => console.log('resize');
  window.addEventListener('resize', handler);

  // Cleanup: remove the listener
  return () => {
    window.removeEventListener('resize', handler);
  };
});

Common uses for cleanup:

  • Removing event listeners
  • Clearing timers/intervals
  • Canceling network requests
  • Closing connections

An effect in a component is not disposed for you

effect() returns a dispose function, and inside a component nothing calls it: the effect (and its cleanup) keeps running after the component unmounts. Register the disposer yourself, or use useEffect, which registers itself with the component.

import { effect, onCleanup } from 'what-framework';

function Widget({ userId }) {
  const dispose = effect(() => {
    console.log('watching', userId());
  });
  onCleanup(dispose);

  return <div />;
}

A module-scope effect has no component to belong to and lives for the life of the page. That is by design, and most of the examples below are that kind.

Practical Examples

Document Title

const unreadCount = signal(0);

effect(() => {
  const count = unreadCount();
  document.title = count > 0
    ? `(${count}) My App`
    : 'My App';
});

Fetch Data

const userId = signal(1);
const user = signal(null);
const loading = signal(false);

effect(() => {
  const id = userId();
  const controller = new AbortController();
  loading.set(true);

  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => {
      user.set(data);
      loading.set(false);
    })
    .catch(err => {
      if (err.name !== 'AbortError') loading.set(false);
    });

  // Abort the in-flight request before the next run
  return () => controller.abort();
});

Without the abort, a slow response for the old userId can land after a fast one for the new userId and overwrite it. For anything beyond a demo, reach for createResource, useQuery or useSWR, which handle aborting, loading state and errors for you. See Data Fetching.

Local Storage Sync

const theme = signal(
  localStorage.getItem('theme') || 'light'
);

effect(() => {
  localStorage.setItem('theme', theme());
});

This one and the document-title example above both touch browser globals at module scope, and an effect body runs the moment the module is evaluated. If the same module is ever imported on the server that throws before any component renders, so guard the access (typeof localStorage !== 'undefined') or move the work into onMount, which never runs on the server.

Debounced Search

const query = signal('');
const results = signal([]);

effect(() => {
  const q = query();
  if (!q) {
    results.set([]);
    return;
  }

  const timeout = setTimeout(() => {
    fetch(`/api/search?q=${q}`)
      .then(r => r.json())
      .then(data => results.set(data));
  }, 300);

  return () => clearTimeout(timeout);
});

Effects vs Computed

Use computed for derived values (pure transformations):

// GOOD - derived value
const fullName = computed(() => `${first()} ${last()}`);

Use effect for side effects (interactions with the outside world):

// GOOD - side effect
effect(() => {
  document.title = fullName();
});

Avoid: Effects that just set signals

// BAD - use computed instead
const doubled = signal(0);
effect(() => {
  doubled.set(count() * 2);
});

// GOOD
const doubled = computed(() => count() * 2);

Conditional Tracking

Only signals read during execution are tracked:

const showDetails = signal(false);
const details = signal('...');

effect(() => {
  if (showDetails()) {
    console.log(details());  // Only tracks details when showDetails is true
  }
});

details.set('new');  // Doesn't trigger (showDetails is false)
showDetails.set(true);  // Triggers, now details is tracked
details.set('newer');  // Triggers

Reading Without Tracking

Use peek() or untrack() to read without creating a subscription:

import { untrack } from 'what-framework';

const count = signal(0);
const multiplier = signal(2);

effect(() => {
  // Re-runs when count changes, NOT when multiplier changes
  const result = count() * multiplier.peek();
  console.log(result);
});

// Alternative syntax
effect(() => {
  const result = count() * untrack(() => multiplier());
  console.log(result);
});

Best Practices

  • Keep effects focused. One effect, one purpose.
  • Always clean up. If you add a listener, remove it in cleanup.
  • Prefer computed for derived values. Effects are for side effects only.
  • Don't modify signals you're reading. An effect that writes a signal it also reads re-triggers itself, and What stops it with a loop warning. Use peek(), untrack(), or the updater form count.set(c => c + 1), none of which subscribe.
  • Dispose effects you create in a component. Pass the returned disposer to onCleanup, or use useEffect.
  • Use onMount for one-time setup. Don't use effect when you just need code to run once.
  • Handle errors. Wrap async code in try/catch.