Effects, Computed, and Watch
These are the three ways to react to state changes. Each serves a different purpose.
Quick comparison
| Tool | What it does | When it runs | Returns |
|---|---|---|---|
effect() | Runs a function when dependencies change | Immediately + on every change | Cleanup function |
computed() | Calculates a derived value lazily | Only when the value is read | The state (for chaining) |
watch() | Calls a callback when a specific property changes | Only when the watched value changes | Cleanup function |
effect() — Run code when state changes
What is it?
effect() takes a function, runs it immediately, and then re-runs it automatically whenever any reactive property it read changes.
Syntax
const cleanup = effect(() => {
// This code runs now, and re-runs when dependencies change
});
// Later: stop the effect
cleanup();Basic example
const app = state({ count: 0 });
const stop = effect(() => {
console.log('Count is:', app.count);
});
// Output: "Count is: 0" (runs immediately)
app.count = 1; // Output: "Count is: 1"
app.count = 2; // Output: "Count is: 2"
// Stop watching
stop();
app.count = 3; // No output — effect was cleaned upHow dependency tracking works
const app = state({ name: 'Alice', age: 30, color: 'blue' });
effect(() => {
// This effect reads name and age, but NOT color
console.log(`${app.name} is ${app.age}`);
});
app.name = 'Bob'; // Effect re-runs (name is a dependency)
app.age = 31; // Effect re-runs (age is a dependency)
app.color = 'red'; // Effect does NOT re-run (color is not a dependency)The system only tracks properties that are actually read during the effect's execution.
DOM updates with effects
const app = state({ theme: 'light' });
effect(() => {
document.body.className = app.theme === 'dark' ? 'dark-mode' : 'light-mode';
});
app.theme = 'dark'; // Body class changes automaticallyMultiple effects
const app = state({ count: 0, name: 'World' });
// Effect 1 — watches count
effect(() => {
Elements.counter.update({ textContent: app.count });
});
// Effect 2 — watches name
effect(() => {
Elements.greeting.update({ textContent: `Hello, ${app.name}` });
});
// Effect 3 — watches both
effect(() => {
document.title = `${app.name} (${app.count})`;
});
app.count = 5;
// Effect 1 re-runs ✅ (depends on count)
// Effect 2 does NOT re-run (depends on name, not count)
// Effect 3 re-runs ✅ (depends on both)Bulk effects
Create multiple effects at once:
const cleanup = effects({
updateTitle: () => {
document.title = app.name;
},
updateCounter: () => {
Elements.count.update({ textContent: app.count });
},
updateTheme: () => {
document.body.className = app.theme;
}
});
// Stop all effects at once
cleanup();computed() — Derived values
What is it?
A computed property is a value that is calculated from other state. It's lazy — it only recalculates when one of its dependencies changes AND someone reads it.
Syntax
const app = state({ price: 100, taxRate: 0.1 });
// Add a computed property
computed(app, {
total: function() {
return this.price + (this.price * this.taxRate);
}
});
console.log(app.total); // 110How it works
app.total is read
↓
1️⃣ Is the computed marked as "dirty"?
├── No → return cached value (fast!)
└── Yes → continue
↓
2️⃣ Run the compute function
↓
3️⃣ Cache the result
↓
4️⃣ Mark as "clean"
↓
5️⃣ Return the valueThe "dirty" mechanism
const app = state({ firstName: 'Alice', lastName: 'Smith' });
computed(app, {
fullName: function() {
console.log('Computing fullName...');
return `${this.firstName} ${this.lastName}`;
}
});
console.log(app.fullName); // "Computing fullName..." → "Alice Smith"
console.log(app.fullName); // "Alice Smith" (no log — uses cached value!)
console.log(app.fullName); // "Alice Smith" (still cached)
app.firstName = 'Bob'; // Marks fullName as "dirty"
console.log(app.fullName); // "Computing fullName..." → "Bob Smith" (recalculated)
console.log(app.fullName); // "Bob Smith" (cached again)The computed function only runs when:
- A dependency changed (marked dirty), AND
- Someone actually reads the computed value
Computed in effects
Computed properties work seamlessly with effects:
const cart = state({ price: 50, quantity: 2, taxRate: 0.08 });
computed(cart, {
subtotal: function() {
return this.price * this.quantity;
},
total: function() {
return this.subtotal * (1 + this.taxRate);
}
});
effect(() => {
Elements.total.update({ textContent: `$${cart.total.toFixed(2)}` });
});
// Shows: "$108.00"
cart.quantity = 3;
// subtotal recalculates → total recalculates → effect re-runs
// Shows: "$162.00"Chaining computed
computed() accepts multiple properties in one call — just add more keys to the object:
const app = state({ width: 10, height: 5 });
computed(app, {
area: function() { return this.width * this.height; },
perimeter: function() { return 2 * (this.width + this.height); },
isSquare: function() { return this.width === this.height; }
});
console.log(app.area); // 50
console.log(app.perimeter); // 30
console.log(app.isSquare); // falseFunctional API for computed
You can also add computed properties using the functional API:
const app = state({ count: 0 });
computed(app, {
doubled: function() { return this.count * 2; },
isEven: function() { return this.count % 2 === 0; }
});
console.log(app.doubled); // 0
console.log(app.isEven); // truewatch() — React to specific changes
What is it?
watch() calls a callback whenever a specific property (or computed expression) changes. Unlike effect(), it gives you both the new and old values.
Syntax — watch a property by name
const app = state({ count: 0 });
const stop = watch(app, 'count', (newValue, oldValue) => {
console.log(`count changed from ${oldValue} to ${newValue}`);
});
app.count = 5; // "count changed from 0 to 5"
app.count = 10; // "count changed from 5 to 10"
// Stop watching
stop();
app.count = 20; // No output — watcher stoppedSyntax — watch a computed expression
const app = state({ firstName: 'Alice', lastName: 'Smith' });
const stop = watch(app,
function() { return `${this.firstName} ${this.lastName}`; },
(newValue, oldValue) => {
console.log(`Name changed from "${oldValue}" to "${newValue}"`);
}
);
app.firstName = 'Bob'; // "Name changed from "Alice Smith" to "Bob Smith""Effect vs Watch — when to use which
const app = state({ count: 0 });
// EFFECT — run side effects, no old/new comparison
effect(() => {
Elements.count.update({ textContent: app.count });
});
// WATCH — react to changes with old/new values
watch(app, 'count', (newVal, oldVal) => {
console.log(`Changed: ${oldVal} → ${newVal}`);
if (newVal > 100) alert('Count is very high!');
});Use effect() when you want to keep the DOM in sync with state.
Use watch() when you need to know what changed (old vs new) or want to run logic only when a specific property changes.
Functional API for watch
const app = state({ count: 0, name: 'Alice' });
const cleanup = watch(app, {
count: (newVal, oldVal) => console.log('count:', oldVal, '→', newVal),
name: (newVal, oldVal) => console.log('name:', oldVal, '→', newVal)
});
app.count = 5; // "count: 0 → 5"
app.name = 'Bob'; // "name: Alice → Bob"
// Stop all watchers
cleanup();Combining all three
Here's how effect, computed, and watch work together:
const app = state({
items: [],
taxRate: 0.08
});
// Computed — derived values
computed(app, {
itemCount: function() {
return this.items.length;
},
subtotal: function() {
return this.items.reduce((sum, item) => sum + item.price, 0);
},
total: function() {
return this.subtotal * (1 + this.taxRate);
}
});
// Effect — keep DOM in sync
effect(() => {
Elements.update({
count: { textContent: app.itemCount },
subtotal: { textContent: `$${app.subtotal.toFixed(2)}` },
total: { textContent: `$${app.total.toFixed(2)}` }
});
});
// Watch — react to specific changes
watch(app, 'itemCount', (newCount, oldCount) => {
if (newCount > oldCount) {
showNotification('Item added to cart!');
}
});
// Everything works together
app.items.push({ name: 'Widget', price: 25 });
// 1. itemCount, subtotal, total recalculate
// 2. Effect re-runs — DOM updates
// 3. Watch fires — notification shownCommon patterns
Pattern 1: Conditional effects
effect(() => {
Elements.update({
profile: { hidden: !app.isLoggedIn },
loginBtn: { hidden: app.isLoggedIn }
});
});Pattern 2: Computed validation
const form = state({ email: '', password: '' });
computed(form, {
isValid: function() {
return this.email.includes('@') && this.password.length >= 8;
}
});
effect(() => {
Elements.submitBtn.update({ disabled: !form.isValid });
});Pattern 3: Watch for side effects
watch(app, 'theme', (newTheme) => {
localStorage.setItem('theme', newTheme);
});
watch(app, 'language', (newLang) => {
document.documentElement.lang = newLang;
});Key takeaways
- effect() — runs immediately, re-runs on dependency changes, auto-tracks dependencies
- computed() — lazy derived values, only recalculates when dirty AND read
- watch() — explicit property watching, gives old and new values
- Effects are for DOM updates — keep the UI in sync
- Computed is for derived data — avoid manual recalculation
- Watch is for reactions — side effects that need old/new comparison
- All three return cleanup functions — call them to stop watching
What's next?
Now let's explore the instance methods that every reactive object gets — including update(), set(), batch(), and bind() for cases where the instance-style API is preferred.
Let's continue!