Primitives
•9 min read
Effects
Effects run side effects without blocking the reconciliation loop.
Basic Effects
Define effects in your module to react to fact changes:
import { createModule, t } from '@directive-run/core';
const analyticsModule = createModule("analytics", {
schema: {
facts: {
page: t.string(),
userId: t.string().nullable(),
},
},
effects: {
trackPageView: {
// Fires whenever page or userId changes
run: (facts) => {
analytics.track("page_view", {
page: facts.page,
userId: facts.userId,
});
},
},
},
});
Effect Anatomy
| Property | Type | Description |
|---|---|---|
run | (facts, prevFacts, derived) => void | Promise<void> | (() => void) | The side effect to execute. May return a cleanup function. |
deps | string[] | Optional explicit dependencies – fact keys or derivation IDs |
The run function receives:
facts– the current facts (read-only access recommended)prevFacts– a snapshot of all facts from before the last change, ornullon first runderived– this module's derivations, as values
Reading a derivation
run() receives the module's derivations as its third argument. Read them there rather than reaching for the system:
effects: {
announceMilestone: {
run: (facts, prevFacts, derived) => {
if (derived.hasHitTarget && !prevFacts?.celebrated) {
confetti();
}
},
},
}
The parameter is additive – a run() that only takes (facts, prevFacts) keeps working exactly as before. Same three tracking carve-outs as constraints: deps declared, or a read after an await, means the read no longer registers a dependency. See Constraints for the full note, including why reaching through system.derive from inside a module breaks once the module is composed.
Effects never write facts
An effect talks to the outside world: a log line, the DOM, a socket, a canvas. The facts it receives is a read-only view. Assigning to it, deleting from it, or writing through facts.$store throws in development and is ignored in production. That holds for writes after an await, inside the cleanup the effect returns, and in any timer or listener the effect starts. In development, objects and arrays read through the view are read-only too, so facts.items.push(x) throws instead of mutating the store unseen.
| The write you wanted | Where it goes |
|---|---|
| A change caused by input | an event handler |
| A value computed from other facts | a derivation |
| Work a condition requires | a constraint + resolver |
| Data arriving from outside (socket, timer, browser event) | a source that publishes an event |
// Before: throws in development
effects: {
total: { run: (facts) => { facts.total = facts.price * facts.qty; } },
}
// After: a derivation
derive: {
total: (facts) => facts.price * facts.qty,
}
An effect can still dispatch an event (system.events.x(...)), and that event's handler writes facts as usual. TypeScript types facts as Readonly, so a direct assignment fails at compile time; the runtime check covers the rest.
Before 1.35.0
A fact written from an effect was accepted, but only half handled. Constraints and derivations saw the write. Other effects did not, so a view effect could stay on the old value with no error anywhere. If you relied on that write, move the line to one of the primitives in the table above.
Auto-Tracking
By default, effects auto-track which facts are read during run(). On subsequent changes, the effect only re-runs if one of its tracked facts changed:
effects: {
logUser: {
// Auto-tracks facts.userId and facts.userName
// Only re-runs when those specific facts change
run: (facts) => {
console.log(`User: ${facts.userId} - ${facts.userName}`);
},
},
}
Conditional reads
An effect wakes only for the facts its last run read. A short-circuit skips the read:
run: (facts) => {
if (facts.muted || facts.volume > 80) warn(); // while muted is true, volume is never read
}
While muted is true, a change to volume alone does not wake the effect. Read both up front (const { muted, volume } = facts;) or declare deps: ['muted', 'volume'] when the body acts on both.
Previous Values
Access the previous facts snapshot to detect transitions:
effects: {
onStatusChange: {
run: (facts, prevFacts) => {
// Detect specific transitions using previous values
if (prevFacts && prevFacts.status === "pending" && facts.status === "complete") {
confetti.launch();
notifyUser("Order complete!");
}
if (prevFacts && prevFacts.status === "processing" && facts.status === "failed") {
errorReporter.capture("Order failed");
}
},
},
}
prevFacts is null on the first run (no previous state exists yet).
Explicit Dependencies
Use deps to declare which facts an effect depends on. This is required for async effects where fact reads after await won't be auto-tracked:
effects: {
// Sync effects use auto-tracking (no deps needed)
syncEffect: {
run: (facts) => {
console.log(facts.userId); // Tracked automatically
},
},
// Async effects need explicit deps – reads after await aren't tracked
asyncEffect: {
deps: ["userId", "userName"],
run: async (facts) => {
await someAsyncOp();
console.log(facts.userId); // Safe – dep is declared explicitly
},
},
}
Why? Auto-tracking only captures synchronous fact reads. Any reads that happen after an await are invisible to the tracker. Explicit deps guarantee the effect runs when those facts change.
DOM Effects
Effects are perfect for DOM manipulation:
effects: {
// Update the browser tab title when page changes
updateTitle: {
deps: ["pageTitle"],
run: (facts) => {
document.title = facts.pageTitle
? `${facts.pageTitle} | MyApp`
: "MyApp";
},
},
// Show a badge favicon when there are unread items
updateFavicon: {
deps: ["unreadCount"],
run: (facts) => {
const favicon = document.querySelector("link[rel='icon']");
if (favicon) {
favicon.href = facts.unreadCount > 0
? "/favicon-badge.ico"
: "/favicon.ico";
}
},
},
// Scroll to top on route changes
scrollToTop: {
run: (facts, prevFacts) => {
if (prevFacts && facts.currentRoute !== prevFacts.currentRoute) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
},
},
}
External Service Integration
Connect to external services reactively:
effects: {
// Sync user profile changes to Firebase in real time
syncToFirebase: {
deps: ["userProfile"],
run: (facts) => {
if (facts.userProfile) {
firebase.database()
.ref(`users/${facts.userProfile.id}`)
.set(facts.userProfile);
}
},
},
// Keep Intercom in sync with user data
sendToIntercom: {
deps: ["user"],
run: (facts) => {
if (facts.user) {
Intercom("update", {
user_id: facts.user.id,
email: facts.user.email,
name: facts.user.name,
});
}
},
},
}
Cleanup
Effects can return a cleanup function, similar to React's useEffect. The cleanup runs before the effect re-runs (when deps change) and when the system stops or is destroyed:
effects: {
// Return a cleanup function to tear down resources
websocket: {
deps: ["roomId"],
run: (facts) => {
const ws = new WebSocket(`wss://chat.example.com/${facts.roomId}`);
ws.onmessage = (e) => handleMessage(e.data);
// Cleanup: close the connection when roomId changes or system stops
return () => ws.close();
},
},
// Works with intervals, event listeners, etc.
polling: {
deps: ["endpoint"],
run: (facts) => {
const id = setInterval(() => fetch(facts.endpoint), 5000);
return () => clearInterval(id);
},
},
}
Cleanup functions are called safely – errors in cleanup are caught and logged without breaking the system. If an async effect returns a cleanup function after the system has already been stopped, the cleanup is invoked immediately so resources are never leaked.
Error Isolation
Effects are fire-and-forget – errors are logged but never break the reconciliation loop:
effects: {
riskyEffect: {
run: (facts) => {
// If this throws, the reconciliation loop continues normally
// Errors are logged and reported via the onError callback
externalService.send(facts.data);
},
},
}
For async effects, handle errors explicitly to avoid unhandled promise rejections:
effects: {
saveData: {
deps: ["data"],
run: async (facts) => {
try {
await api.save(facts.data);
} catch (error) {
// Handle errors explicitly to avoid unhandled promise rejections
errorReporter.capture(error);
}
},
},
}
Parallel Execution
Effects run in parallel, not sequentially. They are independent side effects and don't wait for each other:
effects: {
// All three effects run in parallel – they don't wait for each other
logEvent: {
run: (facts) => console.log("Action:", facts.action),
},
trackAnalytics: {
run: (facts) => analytics.track(facts.action),
},
notifyUser: {
run: (facts) => showNotification(facts.action),
},
}
Effects after a rewind
goBack(), goForward(), goTo(), replay() and import() restore facts without reconciling them. Resolvers don't start. Effects do run again for the restored facts they depend on, so a canvas, the DOM or the document title matches the rewound state. That pass takes no snapshot, so redo still works, and it does not re-attach gated sources.
Some effects record acts instead of mirroring state, such as analytics, an audit log, or anything that dispatches an event or opens a connection. Opt those out:
effects: {
trackView: {
deps: ['page'],
onNavigate: 'skip', // default: 'rerun'
run: (facts) => analytics.page(facts.page),
},
}
onNavigate works the same on module effects, cross-module effects and system.effects.register(). In development, an effect that dispatches an event during a rewind pass logs a warning suggesting onNavigate: 'skip'.
Best Practices
Don't Mutate Facts
Effects should be read-only side effects, not state mutations:
// Good - only side effects
effects: {
log: {
run: (facts) => console.log(facts.status),
},
}
// Bad - mutating facts
effects: {
compute: {
run: (facts) => {
facts.computed = facts.a + facts.b; // Throws in development
},
},
}
The bad example throws in development and does nothing in production. Use a derivation for computed values and an event handler for state changes. See Effects never write facts.
Use Explicit Deps for Async Effects
// Bad - fact reads after await won't be tracked
effects: {
bad: {
run: async (facts) => {
await delay(100);
console.log(facts.userId); // NOT tracked!
},
},
}
// Good - explicit deps for async
effects: {
good: {
deps: ["userId"],
run: async (facts) => {
await delay(100);
console.log(facts.userId); // Works because dep is declared
},
},
}
Handle Async Errors
effects: {
save: {
deps: ["data"],
run: async (facts) => {
try {
await api.save(facts.data);
} catch (error) {
errorReporter.capture(error);
}
},
},
}
Runtime Control & Registration
Effects support enable/disable toggling and dynamic registration:
system.effects.disable("expensiveAnalytics");
system.effects.enable("expensiveAnalytics");
system.effects.register("logger", { run: (facts) => console.log(facts) });
system.effects.assign("existing", { run: (facts) => { /* ... */ } });
system.effects.unregister("logger");
All four subsystems (constraints, effects, resolvers, derivations) share the same registration interface. See Runtime Dynamics for the full semantics table, introspection methods, and use cases.
Effects vs Resolvers
| Aspect | Effects | Resolvers |
|---|---|---|
| Purpose | Side effects (logging, DOM, analytics) | Fulfill requirements (API calls, data) |
| Trigger | Fact changes | Constraint activation |
| Can modify facts | No (read-only recommended) | Yes |
| Cleanup support | Yes (return function from run) | Yes (via AbortController) |
| Fire-and-forget | Yes | No (tracked, retried, cancelled) |
| Error handling | Isolated (logged, never breaks engine) | Full lifecycle (retry, timeout, abort) |
| Execution | Parallel | Parallel (sequential via after) |
Definition Meta
Attach optional metadata for better debugging and devtools:
effects: {
logPhase: {
run: (facts) => console.log(facts.phase),
meta: { label: "Phase Logger", category: "logging" },
},
},
See Definition Meta for the full API.
Next Steps
- Constraints – Declarative rules
- Resolvers – Handling requirements
- Events – State mutations
- Module & System – Composing modules
- Choosing Primitives – When to use what

