Skip to content

effect()

The effect() function runs code when its dependencies change. Unlike computed(), effects are for side effects — DOM updates, logging, API calls, etc.

const dispose = effect(fn)
const dispose = effect(flushStrategy)

fn : A function to run. Any signals read inside become dependencies. Can optionally return a cleanup function.

flushStrategy : A flush strategy wrapper like mt(fn), raf(fn), or debounce(ms, fn). See Flush Strategies.

A dispose function that stops the effect and runs any cleanup.

Effects run immediately when created, then re-run whenever their dependencies change. They’re synchronous by default — when a signal changes, the effect runs before the next line of code.

const count = signal(0);
effect(() => {
console.log('Count is:', count());
});
// logs: "Count is: 0"
count(1);
// logs: "Count is: 1"
count(2);
// logs: "Count is: 2"

Return a function from your effect to clean up before the next run:

const userId = signal(1);
effect(() => {
const id = userId();
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(user => console.log(user));
return () => controller.abort();
});

Cleanup runs:

  • Before each re-run
  • When the effect is disposed

Call the returned function to stop the effect:

const dispose = effect(() => {
console.log('Count:', count());
});
count(1); // logs
count(2); // logs
dispose();
count(3); // nothing — effect stopped

By default, effects run synchronously. Use flush strategies to control timing:

import { mt, raf, debounce } from '@rimitive/signals/extend';

Defers to the next microtask. Multiple synchronous updates trigger one effect run:

const a = signal(1);
const b = signal(2);
effect(mt(() => {
console.log('Sum:', a() + b());
}));
// logs: "Sum: 3"
a(10);
b(20);
// logs: "Sum: 30" (once, not twice)

Defers to the next animation frame. Ideal for DOM work:

effect(raf(() => {
canvas.width = width();
canvas.height = height();
ctx.fillRect(0, 0, width(), height());
}));

Waits until dependencies stop changing for the specified duration:

const query = signal('');
effect(debounce(300, () => {
performSearch(query());
}));
StrategyUse case
(none)Immediate response, single dependency, predictable sync behavior
mt(fn)Multiple signals updating together, coalesce into one run
raf(fn)DOM work, canvas, animations
debounce(ms, fn)User input, search, expensive operations
const theme = signal('dark');
effect(() => {
localStorage.setItem('theme', theme());
});
const roomId = signal('general');
effect(() => {
const id = roomId();
const ws = new WebSocket(`wss://chat.example.com/${id}`);
ws.onmessage = (e) => {
messages([...messages(), JSON.parse(e.data)]);
};
return () => ws.close();
});
const x = signal(0);
const y = signal(0);
effect(raf(() => {
element.style.transform = `translate(${x()}px, ${y()}px)`;
}));

Return an effect from .ref() to tie it to an element’s lifetime:

el('canvas')
.ref((canvas) =>
effect(() => {
const ctx = canvas.getContext('2d');
drawScene(ctx, sceneData());
})
)()

The effect disposes automatically when the element is removed.

// BAD — infinite loop
effect(() => {
count(count() + 1);
});
// BAD — use computed instead
const total = signal(0);
effect(() => {
total(items().reduce((a, b) => a + b, 0));
});
// GOOD
const total = computed(() => items().reduce((a, b) => a + b, 0));
// BAD — leaks connections
effect(() => {
const ws = new WebSocket(url());
ws.onmessage = handleMessage;
});
// GOOD
effect(() => {
const ws = new WebSocket(url());
ws.onmessage = handleMessage;
return () => ws.close();
});
  • signal() — Create reactive state
  • computed() — Derive values (prefer over effect for derived state)
  • batch() — Batch multiple updates