Side Effects
Side effects are operations that reach outside the reactive system, such as modifying the DOM, making API calls, or setting timers. You can define these operations to run automatically whenever their dependencies change.
Effect
You can execute code immediately and automatically re-run it whenever the state it accesses changes. This creates a reactive link between your state and the outside world.
import { setup, mutable, effect } from '@anchorlib/react';
export const Logger = setup(() => {
const state = mutable({ count: 0 });
// 1. Runs immediately
// 2. Automatically tracks 'state.count'
// 3. Re-runs whenever 'state.count' changes
effect(() => {
console.log('Count changed to:', state.count);
});
return () => <button onClick={() => state.count++}>Increment</button>;
});⚠️ Automatic Tracking
Anchor tracks every reactive property accessed synchronously within the effect. This includes properties accessed inside helper functions, loops, or serialization methods like JSON.stringify(). If you read it, you subscribe to it.
Managing Resources
Effects can return a cleanup function. This function runs:
- Before the effect re-runs (due to a dependency change).
- When the component unmounts.
This is essential for cleaning up timers, subscriptions, or event listeners.
const state = mutable({ delay: 1000 });
effect(() => {
// This effect depends on 'state.delay'
const id = setInterval(() => {
console.log('Tick');
}, state.delay);
// Cleanup runs when:
// 1. 'state.delay' changes (before the new interval starts)
// 2. Component unmounts
return () => {
clearInterval(id);
console.log('Timer cleared');
};
});Reading Without Subscribing
Sometimes you need to read a reactive value inside an effect without subscribing to it. This allows you to use the current value of a state without triggering a re-run when that state updates.
You can use untrack() to ignore specific dependencies.
untrack runs the provided function and returns its result, but ignores any reactive property accesses that happen inside it.
import { effect, untrack } from '@anchorlib/react';
effect(() => {
// 1. Trigger: Run whenever the document content changes
const content = doc.content;
// 2. Untrack value: Get the current API endpoint
// Changing the API URL in settings shouldn't force an immediate save
const endpoint = untrack(() => settings.saveUrl);
// 3. Untrack execution: Perform the fetch
// We don't want to track 'auth.token' here either
untrack(() => {
fetch(endpoint, {
method: 'POST',
body: JSON.stringify({ content }),
headers: { 'Authorization': auth.token }
});
});
});In this example:
- The effect re-runs when
doc.contentchanges. - The effect does not re-run when
settings.saveUrlorauth.tokenchanges, even though we read them.
Snapshots
Alternatively, you can create a safe copy of the state using snapshot(). This creates a deep clone of the current state that is not reactive, making it safe for serialization or logging.
import { snapshot, effect } from '@anchorlib/react';
effect(() => {
// snapshot(state) returns a deep copy (clone)
// Perfect for serialization or sending to an API
const copy = snapshot(state);
const json = JSON.stringify(copy); // Safe! No tracking.
});Performance
snapshot() performs a deep clone by default, which ensures complete safety but adds overhead. If you need a faster clone and are sure you won't accidentally mutate nested properties, you can use snapshot(state, false) to perform a shallow copy.
Both are safe for serialization because the returned object is a plain JavaScript object, detached from the reactivity system.
Global Observability
When you need to listen to any change in a state object (for example, to trigger a log or a unified save), using effect can be tedious because you have to manually access every property to track it.
For this, Anchor provides the subscribe function.
subscribe(state, handler, recursive? = true)
- state: The reactive object.
- handler: A function called with the new state and the event details.
- recursive: Whether to listen to nested changes (default:
true).
import { subscribe } from '@anchorlib/react';
const user = mutable({ name: 'John', settings: { theme: 'dark' } });
// Triggers on ANY change to 'user' or its children
subscribe(user, (val, event) => {
console.log('Something changed!', event);
console.log('New State:', val);
});
user.settings.theme = 'light'; // Triggers the subscribersubscribe vs effect
| Feature | effect(() => ...) | subscribe(state, ...) |
|---|---|---|
| Tracking | Automatic & Granular. Tracks only what you read. | Global. Tracks the entire object tree. |
| Execution | Runs immediately, then on updates. | Runs only on updates. |
| Best For | UI updates, precise side effects. | Logging, debugging, etc. |
Comparison with React Hooks
If you are coming from React, effect is similar to useEffect, but with major improvements:
- No Dependency Array: You never need to manually list dependencies. Anchor tracks them automatically.
- No Stale Closures: Since
effectis usually defined insidesetup(which runs once), it always has access to the latest scope. - Synchronous (by default): Effects run synchronously after state changes (unless batched), ensuring consistency.
- Dynamic Dependency Tracking: Unlike
useEffect, which tracks dependencies statically (via the array),effecttracks dependencies dynamically based on execution path.
effect(() => {
if (state.showDetails) {
console.log(state.details); // Tracks 'details' ONLY if 'showDetails' is true
}
});- If
showDetailsisfalse,state.detailsis NOT tracked. Changingdetailswill NOT trigger the effect. - If
showDetailsbecomestrue, the effect re-runs, readsdetails, and starts tracking it.
Why this matters:
- Performance: Your effect only re-runs when relevant data changes. If a branch is not taken, its dependencies don't cause updates.
- Correctness: You don't need to worry about "stale" dependencies or manually managing dependency arrays. The system always knows exactly what the effect needs right now.
Best Practices
1. Keep Effects Focused
Don't put unrelated logic in a single effect. Create multiple effects for different concerns.
// ❌ Bad: Mixed concerns
effect(() => {
console.log(user.name);
document.title = settings.title;
});
// ✅ Good: Separate effects
effect(() => console.log(user.name));
effect(() => document.title = settings.title);2. Avoid Circular Dependencies
While Anchor prevents simple infinite loops (like state.count++ inside an effect) by logging an error, you should still avoid Circular Dependencies between multiple effects.
// ❌ Circular Dependency Risk
effect(() => {
if (theme.mode === 'light') settings.color = 'blue';
});
effect(() => {
if (settings.color === 'blue') theme.mode = 'light';
});This creates a cycle: Effect A updates settings -> triggers Effect B -> updates theme -> triggers Effect A. Anchor will eventually catch this stack overflow, but it's bad logic.
3. Control Dependencies
Anchor tracks everything you access. Be careful with operations that read too much data, like JSON.stringify(state) or iterating over object keys, as they will subscribe to every property.
Use untrack() or snapshot() to safely read data without over-subscribing. (See Untracking Dependencies above).
// ❌ Reads every property -> Updates on ANY change
effect(() => console.log(JSON.stringify(user)));
// ✅ Snapshot reads once (safe copy) -> Updates ONLY when needed (if tracking upstream)
effect(() => {
const copy = snapshot(user);
console.log(JSON.stringify(copy));
});