Effects
Effects are reactive side effects that automatically re-run when their dependencies change. They're the Storion equivalent of React's useEffect, but they work at the store level and track dependencies automatically.
The Problem
In React, useEffect requires manual dependency arrays that are easy to get wrong:
// ❌ React useEffect - manual dependencies, easy to forget
useEffect(() => {
document.title = `${user.name} - ${count} items`;
}, [user.name, count]); // Did we forget any deps?Storion effects track dependencies automatically:
// ✅ Storion effect - automatic tracking
effect(() => {
document.title = `${state.user.name} - ${state.count} items`;
// Dependencies tracked automatically from state reads
});Basic Usage
Effects are typically defined in store setup:
import { store, effect } from "storion/react";
const userStore = store({
name: "user",
state: { name: "", theme: "light" },
setup({ state }) {
// This effect auto-runs when state.theme changes
effect(() => {
document.body.className = state.theme;
});
// This effect auto-runs when state.name changes
effect(() => {
document.title = `Welcome, ${state.name}`;
});
return {
setName: (name: string) => {
state.name = name;
},
setTheme: (theme: string) => {
state.theme = theme;
},
};
},
});What happens:
- Effect runs immediately on store creation
- Storion tracks which state properties were read (
theme,name) - When those properties change, the effect re-runs automatically
- No manual dependency array needed
Effect Context
Every effect receives a context object with useful utilities:
effect((ctx) => {
// ctx.nth - Run number (1-indexed)
console.log(`Effect run #${ctx.nth}`);
// ctx.signal - AbortSignal for cancellation
fetch("/api/data", { signal: ctx.signal });
// ctx.onCleanup() - Register cleanup callbacks
ctx.onCleanup(() => {
console.log("Cleaning up before next run");
});
// ctx.safe() - Safe async operations
ctx.safe(fetchData()).then((data) => {
state.data = data;
});
// ctx.refresh() - Manually trigger re-run (async only)
setTimeout(() => ctx.refresh(), 5000);
});Cleanup
Effects often need to clean up resources (timers, subscriptions, event listeners). Use ctx.onCleanup():
effect((ctx) => {
// Set up a timer
const timer = setInterval(() => {
state.tick++;
}, 1000);
// Clean up when effect re-runs or store disposes
ctx.onCleanup(() => {
clearInterval(timer);
});
});When cleanup runs:
- Before effect re-runs — when dependencies change
- When store is disposed — final cleanup
Not Like React useEffect
In React, you return a cleanup function. In Storion, you call ctx.onCleanup():
// ❌ Wrong - React pattern doesn't work
effect(() => {
const timer = setInterval(/*...*/);
return () => clearInterval(timer); // Won't be called!
});
// ✅ Correct - use ctx.onCleanup()
effect((ctx) => {
const timer = setInterval(/*...*/);
ctx.onCleanup(() => clearInterval(timer));
});Why onCleanup() Over Return?
The ctx.onCleanup() pattern handles partial failure correctly:
// React pattern - cleanup doesn't run if setup fails midway
useEffect(() => {
const ws = new WebSocket(url); // ✅ Created
const timer = setInterval(/*...*/); // ✅ Created
const result = riskyOperation(); // ❌ Throws!
return () => {
// Never runs! WebSocket and timer leak.
ws.close();
clearInterval(timer);
};
}, []);
// Storion pattern - each cleanup registered immediately
effect((ctx) => {
const ws = new WebSocket(url);
ctx.onCleanup(() => ws.close()); // ✅ Registered
const timer = setInterval(/*...*/);
ctx.onCleanup(() => clearInterval(timer)); // ✅ Registered
riskyOperation(); // ❌ Throws!
// WebSocket and timer are still cleaned up!
});Key advantage: Register cleanup immediately after each resource is created. If later setup steps fail, earlier cleanups still run.
Async Operations
Effects Must Be Synchronous
Effect callbacks cannot be async. This is intentional - async effects make dependency tracking unreliable.
// ❌ WRONG - async effect
effect(async (ctx) => {
const data = await fetchData();
state.data = data;
});
// ✅ CORRECT - use ctx.safe() for async
effect((ctx) => {
ctx.safe(fetchData()).then((data) => {
state.data = data;
});
});Why ctx.safe()?
ctx.safe() wraps promises to handle stale results:
effect((ctx) => {
// If effect re-runs before fetchData() resolves,
// the old promise's .then() callback is ignored
ctx.safe(fetchData()).then((data) => {
// Only runs if this is still the current effect run
state.data = data;
});
});Without ctx.safe(), you risk race conditions where old responses overwrite newer ones.
Using ctx.signal
For fetch requests, use the built-in abort signal:
effect((ctx) => {
fetch("/api/data", { signal: ctx.signal })
.then((res) => res.json())
.then((data) => {
state.data = data;
})
.catch((err) => {
if (err.name !== "AbortError") {
state.error = err;
}
});
});Derived State
Effects are perfect for computed/derived values:
const userStore = store({
name: "user",
state: {
firstName: "",
lastName: "",
fullName: "", // Derived
},
setup({ state }) {
// Auto-updates fullName when firstName or lastName changes
effect(() => {
state.fullName = `${state.firstName} ${state.lastName}`.trim();
});
return {
setFirstName: (name: string) => {
state.firstName = name;
},
setLastName: (name: string) => {
state.lastName = name;
},
};
},
});Effect Options
effect(fn, {
// Error handling strategy
onError: "throw", // 'throw' | 'ignore' | 'keepAlive' | { retry: config }
});The only option is onError for controlling how errors are handled.
Error Handling
Default: Throw
Errors propagate to the store's onError handler:
const myStore = store({
onError: (error) => {
console.error("Store error:", error);
Sentry.captureException(error);
},
setup({ state }) {
effect(() => {
if (state.invalid) {
throw new Error("Invalid state!");
}
});
},
});Ignore Errors
effect(fn, { onError: "ignore" });Retry on Error
// Retry with default backoff strategy (1s, 2s, 4s...)
effect(fn, {
onError: { retries: 3 },
});
// Retry with fixed delay
effect(fn, {
onError: { retries: 3, delay: 1000 },
});
// Retry with named strategy: "backoff" | "linear" | "fixed" | "fibonacci" | "immediate"
effect(fn, {
onError: { retries: 5, delay: "linear" }, // 1s, 2s, 3s, 4s, 5s
});
// Retry with custom delay function
effect(fn, {
onError: {
retries: 3,
delay: (attempt) => Math.min(100 * 2 ** attempt, 5000),
},
});Unified with async module
Effect retry uses the same delay strategies as abortable wrappers: "backoff", "linear", "fixed", "fibonacci", "immediate".
Manual Refresh
Sometimes you need to re-run an effect manually:
effect((ctx) => {
// Set up polling
const timer = setTimeout(() => {
ctx.refresh(); // Trigger re-run
}, 5000);
ctx.onCleanup(() => clearTimeout(timer));
// Do the actual work
state.data = await fetchLatestData();
});Cannot Refresh Synchronously
Calling ctx.refresh() during effect execution throws an error to prevent infinite loops:
// ❌ WRONG - throws error
effect((ctx) => {
ctx.refresh(); // Error!
});
// ✅ CORRECT - async refresh
effect((ctx) => {
setTimeout(() => ctx.refresh(), 1000);
});Common Patterns
Syncing to External Systems
effect((ctx) => {
// Sync state to localStorage
localStorage.setItem("user", JSON.stringify(state.user));
});WebSocket Connections
effect((ctx) => {
if (!state.userId) return;
const ws = new WebSocket(`/ws?user=${state.userId}`);
ws.onmessage = (event) => {
state.messages.push(JSON.parse(event.data));
};
ctx.onCleanup(() => {
ws.close();
});
});Event Listeners
effect((ctx) => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
state.modalOpen = false;
}
};
document.addEventListener("keydown", handler);
ctx.onCleanup(() => {
document.removeEventListener("keydown", handler);
});
});Conditional Effects
effect(() => {
// Only track state.theme when darkMode is enabled
if (state.settings.darkMode) {
document.body.classList.add("dark");
document.body.style.setProperty("--bg", state.theme.background);
} else {
document.body.classList.remove("dark");
}
});Effects in useStore Selector
Effects can also be defined inside useStore() selectors. This is useful when you need an effect that:
- Accesses component-scope values (refs, props, other hook results)
- Auto-tracks store state
- Is tied to the component's lifecycle
import { useStore, effect } from "storion/react";
function SearchPage() {
const inputRef = useRef<HTMLInputElement>(null);
const location = useLocation(); // React Router hook
const { query, isReady } = useStore(({ get }) => {
const [state] = get(searchStore);
// Effect defined in selector:
// - Runs in useEffect (after render)
// - Has fresh closure over refs, props, hooks
// - Auto-tracks state.isReady access
effect(() => {
if (location.pathname === "/search" && state.isReady) {
inputRef.current?.focus();
}
});
return { query: state.query, isReady: state.isReady };
});
return <input ref={inputRef} value={query} />;
}When to Use Which
| Scenario | Where to Define Effect |
|---|---|
| Store-level side effects (logging, persistence) | Store's setup() |
| Effects that only use store state | Store's setup() |
| Effects that need refs, props, or other hooks | useStore() selector |
| Effects tied to component lifecycle | useStore() selector |
How It Works
- Effects in the selector are collected during render (pure - no immediate side effects)
- After render, they're executed in React's
useEffect - Fresh closure each render = access to current refs/props/hooks
- Store state is auto-tracked for re-runs
- Cleanup runs on unmount or before re-run
Single useEffect Consolidation
Multiple effect() calls are batched into one React useEffect hook internally. This is more efficient than having many separate useEffect hooks:
// Traditional React: N effects = N useEffect calls
useEffect(() => handleResize(), [width]);
useEffect(() => handleScroll(), [scrollY]);
useEffect(() => handleFocus(), [isFocused]);
// Storion: N effects = 1 useEffect call
useStore(({ get, effect }) => {
const [state] = get(layoutStore);
effect(() => handleResize(state.width));
effect(() => handleScroll(state.scrollY));
effect(() => handleFocus(state.isFocused));
return {
/* ... */
};
});Each effect still tracks its own dependencies and re-runs independently — the consolidation is purely an implementation optimization.
Effect-Only Reactivity (No Re-render)
Effects inside useStore can track state without causing component re-renders. If your selector returns nothing reactive, the component won't re-render when effect dependencies change:
function AnalyticsTracker() {
useStore(({ get, effect }) => {
const [state] = get(pageStore);
// This effect re-runs when state.currentPage changes
// BUT the component doesn't re-render (we return nothing)
effect(() => {
analytics.track("pageView", {
page: state.currentPage,
timestamp: Date.now(),
});
});
// Return empty object = no reactive dependencies for rendering
return {};
});
// This component never re-renders after mount!
return null;
}This pattern is useful for:
- Analytics/logging — Track state changes without UI updates
- Background sync — Sync state to external systems silently
- Performance monitoring — Observe state without affecting render performance
You can also use pick() inside effects for even more precise tracking:
useStore(({ get, effect }) => {
const [state] = get(userStore);
effect(() => {
// Only re-runs when state.preferences.theme changes
// (not when other preferences or user fields change)
const theme = pick(state.preferences, "theme");
document.body.className = theme;
});
return {};
});Best Practices
- Keep effects focused — One effect per concern
- Always clean up resources — Use
ctx.onCleanup()for timers, subscriptions, listeners - Use ctx.safe() for async — Prevents race conditions
- Don't return cleanup functions — Use
ctx.onCleanup()instead - Avoid expensive computations — Effects run synchronously and can block
- Prefer store effects for store-only logic — Use selector effects only when you need external values
Next Steps
- Reactivity — How dependency tracking works
- Stores — Where effects live
- effect() API — Complete API reference