storion
v0.9.0
Published
Reactive stores for modern apps. Type-safe. Auto-tracked. Effortlessly composable
Maintainers
Readme
Table of Contents
- What is Storion?
- Features
- Installation
- Quick Start
- Core Concepts
- Working with State
- Reactive Effects
- Async State Management
- Using Stores in React
- API Reference
- Advanced Patterns
- Limitations & Anti-patterns
- Contributing
What is Storion?
Storion is a lightweight state management library that automatically tracks which parts of your state you use and only updates when those parts change.
The core idea is simple:
- You read state → Storion remembers what you read
- That state changes → Storion updates only the components that need it
No manual selectors. No accidental over-rendering. Just write natural code.
function Counter() {
const { count, inc } = useStore(({ get }) => {
const [state, actions] = get(counterStore);
return { count: state.count, inc: actions.inc };
});
return <button onClick={inc}>{count}</button>;
}What Storion does:
- When you access
state.count, Storion notes that this component depends oncount - When
countchanges, Storion re-renders only this component - If other state properties change, this component stays untouched
Features
| Feature | Description | | --------------------------- | ----------------------------------------------------------- | | 🎯 Auto-tracking | Dependencies tracked automatically when you read state | | 🔒 Type-safe | Full TypeScript support with excellent inference | | ⚡ Fine-grained updates | Only re-render what actually changed | | 🧩 Composable | Mix stores, use dependency injection, create derived values | | 🔄 Reactive effects | Side effects that automatically respond to state changes | | 📦 Tiny footprint | ~4KB minified + gzipped | | 🛠️ DevTools | Built-in devtools panel for debugging | | 🔌 Middleware | Extensible with conditional middleware patterns | | ⏳ Async helpers | First-class async state management with cancellation |
Installation
npm install storion
# or
pnpm add storion
# or
yarn add storionFor React integration:
npm install storion reactQuick Start
Single Store (Simplest Approach)
Best for small apps or isolated features.
import { create } from "storion/react";
// Define store + hook in one call
const [counterStore, useCounter] = create({
name: "counter",
state: { count: 0 },
setup({ state }) {
return {
inc: () => state.count++,
dec: () => state.count--,
};
},
});
// Use in React
function Counter() {
const { count, inc } = useCounter((state, actions) => ({
count: state.count,
inc: actions.inc,
}));
return <button onClick={inc}>{count}</button>;
}
// Use outside React
counterStore.actions.inc();
console.log(counterStore.state.count);What Storion does:
- Creates a reactive state container with
{ count: 0 } - Wraps the state so any read is tracked
- When
inc()changescount, Storion notifies only subscribers usingcount - The React hook connects the component to the store and handles cleanup automatically
Multi-Store with Container (Scalable Approach)
Best for larger apps with multiple stores.
import { store, container } from "storion";
import { StoreProvider, useStore } from "storion/react";
// Define stores separately
const authStore = store({
name: "auth",
state: { userId: null as string | null },
setup({ state }) {
return {
login: (id: string) => {
state.userId = id;
},
logout: () => {
state.userId = null;
},
};
},
});
const todosStore = store({
name: "todos",
state: { items: [] as string[] },
setup({ state, update }) {
return {
add: (text: string) => {
update((draft) => {
draft.items.push(text);
});
},
};
},
});
// Create container (manages all store instances)
const app = container();
// Provide to React tree
function App() {
return (
<StoreProvider container={app}>
<Screen />
</StoreProvider>
);
}
// Consume multiple stores
function Screen() {
const { userId, items, add, login } = useStore(({ get }) => {
const [auth, authActions] = get(authStore);
const [todos, todosActions] = get(todosStore);
return {
userId: auth.userId,
items: todos.items,
add: todosActions.add,
login: authActions.login,
};
});
return (
<div>
<p>User: {userId ?? "Not logged in"}</p>
<button onClick={() => login("user-1")}>Login</button>
<ul>
{items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<button onClick={() => add("New todo")}>Add Todo</button>
</div>
);
}What Storion does:
- Each
store()call creates a store specification (a blueprint) - The
container()manages store instances and their lifecycles - When you call
get(authStore), the container either returns an existing instance or creates one - All stores share the same container, enabling cross-store communication
- The container handles cleanup when the app unmounts
Core Concepts
Stores
A store is a container for related state and actions. Think of it as a module that owns a piece of your application's data.
import { store } from "storion";
const userStore = store({
name: "user", // Identifier for debugging
state: {
// Initial state
name: "",
email: "",
},
setup({ state }) {
// Setup function returns actions
return {
setName: (name: string) => {
state.name = name;
},
setEmail: (email: string) => {
state.email = email;
},
};
},
});Naming convention: Use xxxStore for store specifications (e.g., userStore, authStore, cartStore).
Services
A service is a factory function that creates dependencies like API clients, loggers, or utilities. Services are cached by the container.
// Service factory (use xxxService naming)
function apiService(resolver) {
return {
get: (url: string) => fetch(url).then((r) => r.json()),
post: (url: string, data: unknown) =>
fetch(url, { method: "POST", body: JSON.stringify(data) }).then((r) =>
r.json()
),
};
}
function loggerService(resolver) {
return {
info: (msg: string) => console.log(`[INFO] ${msg}`),
error: (msg: string) => console.error(`[ERROR] ${msg}`),
};
}Naming convention: Use xxxService for service factories (e.g., apiService, loggerService, authService).
Using Services in Stores
const userStore = store({
name: "user",
state: { user: null },
setup({ get }) {
// Get services (cached automatically)
const api = get(apiService);
const logger = get(loggerService);
return {
fetchUser: async (id: string) => {
logger.info(`Fetching user ${id}`);
return api.get(`/users/${id}`);
},
};
},
});What Storion does:
- When
get(apiService)is called, the container checks if an instance exists - If not, it calls
apiService()to create one and caches it - Future calls to
get(apiService)return the same instance - This gives you dependency injection without complex configuration
Container
The container is the central hub that:
- Creates and caches store instances
- Creates and caches service instances
- Provides dependency injection
- Manages cleanup and disposal
import { container } from "storion";
const app = container();
// Get store instance
const { state, actions } = app.get(userStore);
// Get service instance
const api = app.get(apiService);
// Clear all instances (useful for testing)
app.clear();
// Dispose container (cleanup all resources)
app.dispose();Reactivity
Storion's reactivity is built on a simple principle: track reads, notify on writes.
// When you read state.count, Storion tracks this access
const value = state.count;
// When you write state.count, Storion notifies all trackers
state.count = value + 1;What Storion does behind the scenes:
- State is wrapped in a tracking layer
- Each read is recorded: "Component A depends on
count" - Each write triggers a check: "Who depends on
count? Notify them." - Only affected subscribers are notified, keeping updates minimal
Working with State
Direct Mutation
For first-level properties, you can assign directly:
const userStore = store({
name: "user",
state: {
name: "",
age: 0,
isActive: false,
},
setup({ state }) {
return {
setName: (name: string) => {
state.name = name;
},
setAge: (age: number) => {
state.age = age;
},
activate: () => {
state.isActive = true;
},
};
},
});Use case: Simple state updates where you're changing a top-level property.
What Storion does:
- Intercepts the assignment
state.name = name - Compares old and new values
- If different, notifies all subscribers watching
name
Nested State with update()
For nested objects or arrays, use update() with an immer-style draft:
const userStore = store({
name: "user",
state: {
profile: { name: "", email: "" },
tags: [] as string[],
},
setup({ state, update }) {
return {
// Update nested object
setProfileName: (name: string) => {
update((draft) => {
draft.profile.name = name;
});
},
// Update array
addTag: (tag: string) => {
update((draft) => {
draft.tags.push(tag);
});
},
// Batch multiple changes
updateProfile: (name: string, email: string) => {
update((draft) => {
draft.profile.name = name;
draft.profile.email = email;
});
},
};
},
});Use case: Any mutation to nested objects, arrays, or when you need to update multiple properties atomically.
What Storion does:
- Creates a draft copy of the state
- Lets you mutate the draft freely
- Compares the draft to the original state
- Applies only the changes and notifies affected subscribers
- All changes within one
update()call are batched into a single notification
Focus (Lens-like Access)
focus() creates a getter/setter pair for any state path:
const settingsStore = store({
name: "settings",
state: {
user: { name: "", email: "" },
preferences: {
theme: "light" as "light" | "dark",
notifications: true,
},
},
setup({ focus }) {
// Create focused accessors
const [getTheme, setTheme] = focus("preferences.theme");
const [getUser, setUser] = focus("user");
return {
// Direct value
setTheme,
// Computed from previous value
toggleTheme: () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
},
// Immer-style mutation on focused path
updateUserName: (name: string) => {
setUser((draft) => {
draft.name = name;
});
},
// Getter for use in effects
getTheme,
};
},
});Use case: When you frequently access a deep path and want cleaner code.
What Storion does:
- Parses the path
"preferences.theme"once at setup time - The getter reads directly from that path
- The setter determines the update type automatically:
- Direct value:
setTheme("dark") - Reducer (returns new value):
setTheme(prev => newValue) - Producer (mutates draft):
setTheme(draft => { draft.x = y })
- Direct value:
Focus setter patterns:
| Pattern | Example | When to use |
| ------------ | ------------------------------- | ----------------------------- |
| Direct value | set("dark") | Replacing the entire value |
| Reducer | set(prev => prev + 1) | Computing from previous value |
| Producer | set(draft => { draft.x = 1 }) | Partial updates to objects |
Reactive Effects
Effects are functions that run automatically when their dependencies change.
Basic Effect
import { store, effect } from "storion";
const userStore = store({
name: "user",
state: {
firstName: "",
lastName: "",
fullName: "",
},
setup({ state }) {
// Effect runs 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;
},
};
},
});Use case: Computed/derived state that should stay in sync with source data.
What Storion does:
- Runs the effect function immediately
- Tracks every state read during execution (
firstName,lastName) - When any tracked value changes, re-runs the effect
- The effect updates
fullName, which notifies its own subscribers
Effect with Cleanup
const syncStore = store({
name: "sync",
state: {
userId: null as string | null,
status: "idle" as "idle" | "connected" | "error",
},
setup({ state }) {
effect((ctx) => {
if (!state.userId) return;
const ws = new WebSocket(`/ws?user=${state.userId}`);
state.status = "connected";
// Cleanup runs before next effect or on dispose
ctx.onCleanup(() => {
ws.close();
state.status = "idle";
});
});
return {
login: (id: string) => {
state.userId = id;
},
logout: () => {
state.userId = null;
},
};
},
});Use case: Managing resources like WebSocket connections, event listeners, or timers.
What Storion does:
- Runs effect when
userIdchanges - Before re-running, calls the cleanup function from the previous run
- When the store is disposed, calls cleanup one final time
- This prevents resource leaks
Effect with Async Operations
Effects must be synchronous, but you can handle async operations safely:
effect((ctx) => {
const userId = state.userId;
if (!userId) return;
// ctx.safe() wraps a promise to ignore stale results
ctx.safe(fetchUserData(userId)).then((data) => {
// Only runs if this effect is still current
state.userData = data;
});
// Or use abort signal for fetch
fetch(`/api/user/${userId}`, { signal: ctx.signal })
.then((res) => res.json())
.then((data) => {
state.userData = data;
});
});Use case: Data fetching that should be cancelled when dependencies change.
What Storion does:
ctx.safe()wraps the promise in a guard- If the effect re-runs before the promise resolves, the guard prevents the callback from executing
ctx.signalis an AbortSignal that aborts when the effect re-runs- This prevents race conditions and stale data updates
Manual Effect Refresh
effect((ctx) => {
// From async code
setTimeout(() => {
ctx.refresh(); // Triggers a re-run
}, 1000);
// Or by returning ctx.refresh
if (needsAnotherRun) {
return ctx.refresh;
}
});Important: You cannot call ctx.refresh() synchronously during effect execution. This throws an error to prevent infinite loops.
Async State Management
Storion provides helpers for managing async operations with loading, error, and success states.
Defining Async State
import { store } from "storion";
import { async } from "storion/async";
interface User {
id: string;
name: string;
}
const userStore = store({
name: "user",
state: {
// Fresh mode: data is undefined during loading
currentUser: async.fresh<User>(),
// Stale mode: preserves previous data during loading (SWR pattern)
userList: async.stale<User[]>([]),
},
setup({ focus }) {
const currentUserAsync = async(
focus("currentUser"),
async (ctx, userId: string) => {
const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
return res.json();
},
{
retry: { count: 3, delay: (attempt) => attempt * 1000 },
onError: (err) => console.error("Failed:", err),
}
);
return {
fetchUser: currentUserAsync.dispatch,
cancelFetch: currentUserAsync.cancel,
refreshUser: currentUserAsync.refresh,
};
},
});Use case: API calls, data fetching, any async operation that needs loading/error states.
What Storion does:
async.fresh<User>()creates initial state:{ status: "idle", data: undefined, error: undefined }- When
dispatch()is called:- Sets status to
"pending" - In fresh mode, clears data; in stale mode, keeps previous data
- Sets status to
- When the promise resolves:
- Sets status to
"success"and stores the data
- Sets status to
- When the promise rejects:
- Sets status to
"error"and stores the error
- Sets status to
- If
cancel()is called, aborts the request viactx.signal
Consuming Async State
function UserProfile() {
const { user, fetchUser } = useStore(({ get }) => {
const [state, actions] = get(userStore);
return { user: state.currentUser, fetchUser: actions.fetchUser };
});
useEffect(() => {
fetchUser("123");
}, []);
if (user.status === "pending") return <Spinner />;
if (user.status === "error") return <Error message={user.error.message} />;
if (user.status === "idle") return null;
return <div>{user.data.name}</div>;
}Async State with Suspense
import { trigger } from "storion";
import { async } from "storion/async";
import { useStore } from "storion/react";
import { Suspense } from "react";
function UserProfile() {
const { user } = useStore(({ get }) => {
const [state, actions] = get(userStore);
// Trigger fetch on mount
trigger(actions.fetchUser, [], "123");
return {
// async.wait() throws if pending (triggers Suspense)
user: async.wait(state.currentUser),
};
});
// Only renders when data is ready
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}What Storion does:
async.wait()checks the async state's status- If
"pending", throws a promise that React Suspense catches - If
"error", throws the error for ErrorBoundary to catch - If
"success", returns the data - When the data arrives, Suspense re-renders the component
Derived Async State
const dashboardStore = store({
name: "dashboard",
state: {
user: async.fresh<User>(),
posts: async.fresh<Post[]>(),
summary: async.fresh<{ name: string; postCount: number }>(),
},
setup({ state, focus }) {
// ... async actions for user and posts ...
// Option 1: Using async.all() - simpler for multiple sources
async.derive(focus("summary"), () => {
const [user, posts] = async.all(state.user, state.posts);
return { name: user.name, postCount: posts.length };
});
// Option 2: Using async.wait() - more control for conditional logic
async.derive(focus("summary"), () => {
const user = async.wait(state.user);
const posts = async.wait(state.posts);
return { name: user.name, postCount: posts.length };
});
return {
/* actions */
};
},
});Use case: Computing a value from multiple async sources.
What Storion does:
- Runs the derive function and tracks dependencies
- If any source is pending/error, the derived state mirrors that status
- If all sources are ready, computes and stores the result
- Re-runs automatically when source states change
When to use each approach:
| Approach | Best for |
| -------------- | ----------------------------------------------------- |
| async.all() | Waiting for multiple sources at once (cleaner syntax) |
| async.wait() | Conditional logic where you may not need all sources |
Using Stores in React
useStore Hook
import { useStore } from "storion/react";
import { trigger } from "storion";
function Component() {
const { count, inc, user } = useStore(({ get, id }) => {
const [counterState, counterActions] = get(counterStore);
const [userState, userActions] = get(userStore);
// Trigger immediately (empty deps = once)
trigger(userActions.fetchProfile, []); // OR trigger(userActions.fetchProfile);
// Trigger on each component mount (id is unique per mount)
trigger(userActions.refresh, [id]);
return {
count: counterState.count,
inc: counterActions.inc,
user: userState.profile,
};
});
return <div>...</div>;
}Selector context provides:
| Property | Description |
| -------------------------- | ---------------------------------------------- |
| get(store) | Get store instance, returns [state, actions] |
| get(service) | Get service instance (cached) |
| create(service, ...args) | Create fresh service instance with args |
| id | Unique ID per component mount |
| once(fn) | Run function once on mount |
Global function trigger() — Call a function when dependencies change (import from "storion").
Stable Function Wrapping
Functions returned from useStore are automatically wrapped with stable references. This means:
- The function reference never changes between renders
- The function always accesses the latest props and state
- Safe to pass to child components without causing re-renders
import { useStore } from "storion/react";
function SearchForm({ userId }: { userId: string }) {
const [query, setQuery] = useState("");
const { search, results } = useStore(({ get }) => {
const [state, actions] = get(searchStore);
return {
results: state.results,
// This function is auto-wrapped with stable reference
search: () => {
// Always has access to current query and userId
actions.performSearch(query, userId);
},
};
});
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* search reference is stable - won't cause Button to re-render */}
<Button onClick={search}>Search</Button>
<Results items={results} />
</div>
);
}
// Button only re-renders when its own props change
const Button = memo(({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
));Use case: Creating callbacks that depend on component state/props but need stable references for memo, useCallback dependencies, or child component optimization.
What Storion does:
- Detects functions in the selector's return value
- Wraps each function with a stable reference (created once)
- When the wrapped function is called, it executes the latest version from the selector
- Component state (
query) and props (userId) are always current when the function runs
Why this matters:
// ❌ Without stable wrapping - new reference every render
const search = () => actions.search(query); // Changes every render!
// ❌ Manual useCallback - verbose and easy to forget deps
const search = useCallback(() => actions.search(query), [query, actions]);
// ✅ With useStore - stable reference, always current values
const { search } = useStore(({ get }) => ({
search: () => actions.search(query), // Stable reference!
}));Trigger Patterns
import { trigger } from "storion";
import { useStore } from "storion/react";
function Dashboard({ categoryId }: { categoryId: string }) {
const { data } = useStore(({ get, id }) => {
const [state, actions] = get(dataStore);
// Pattern 1: Fetch once ever (empty deps)
trigger(actions.fetchOnce, []);
// Pattern 2: Fetch every mount (id changes each mount)
trigger(actions.fetchEveryVisit, [id]);
// Pattern 3: Fetch when prop changes
trigger(actions.fetchByCategory, [categoryId], categoryId);
return { data: state.data };
});
}What Storion does:
trigger()compares current deps with previous deps- If deps changed (or first render), calls the function with provided args
- Empty deps
[]means "call once and never again" [id]means "call every time component mounts" (id is unique per mount)
Comparison with React Query / Apollo:
| Storion | React Query | Apollo | Behavior |
| ------------------------------------------ | ----------------------------------------- | ------------------------------------------------ | ----------------------------- |
| trigger(fetch, []) | useQuery() | useQuery() | Auto-fetch on mount |
| trigger(fetch, [id]) | useQuery({ refetchOnMount: 'always' }) | useQuery({ fetchPolicy: 'network-only' }) | Fetch every mount |
| trigger(fetch, [categoryId], categoryId) | useQuery({ variables: { categoryId } }) | useQuery(QUERY, { variables: { categoryId } }) | Refetch when variable changes |
| Manual dispatch() | useLazyQuery() | useLazyQuery() | Fetch on user action |
// Auto-fetch (like useQuery in React Query / Apollo)
function UserProfile() {
const { user } = useStore(({ get }) => {
const [state, actions] = get(userStore);
trigger(actions.fetchUser, []); // Fetches automatically on mount
return { user: state.user };
});
}
// Lazy fetch (like useLazyQuery in React Query / Apollo)
function SearchResults() {
const { results, search } = useStore(({ get }) => {
const [state, actions] = get(searchStore);
// No trigger - user controls when to fetch
return { results: state.results, search: actions.search };
});
return (
<div>
<button onClick={() => search("query")}>Search</button>
{/* results shown after user clicks */}
</div>
);
}Fine-Grained Updates with pick()
import { pick } from "storion";
function UserName() {
const { name, fullName } = useStore(({ get }) => {
const [state] = get(userStore);
return {
// Re-renders ONLY when this specific value changes
name: pick(() => state.profile.name),
// Computed values are tracked the same way
fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
};
});
return <span>{fullName}</span>;
}Use case: When you need even more precise control over re-renders.
Without pick(): Component re-renders when state.profile reference changes (even if name didn't change).
With pick(): Component only re-renders when the picked value actually changes.
pick() equality options:
const result = useStore(({ get }) => {
const [state] = get(mapStore);
return {
// Default: strict equality (===)
x: pick(() => state.coords.x),
// Shallow: compare object properties one level deep
coords: pick(() => state.coords, "shallow"),
// Deep: recursive comparison
settings: pick(() => state.settings, "deep"),
// Custom: provide your own function
ids: pick(
() => state.items.map((i) => i.id),
(a, b) => a.length === b.length && a.every((v, i) => v === b[i])
),
};
});API Reference
store(options)
Creates a store specification.
import { store } from "storion";
const myStore = store({
name: "myStore",
state: { count: 0 },
setup({ state, update, focus, get, create, onDispose }) {
return {
inc: () => state.count++,
};
},
});Options:
| Option | Type | Description |
| ------------ | ------------------------------ | ------------------------------------------- |
| name | string | Display name for debugging |
| state | TState | Initial state object |
| setup | (ctx) => TActions | Setup function, returns actions |
| lifetime | "singleton" \| "autoDispose" | Instance lifecycle (default: "singleton") |
| equality | Equality \| EqualityMap | Custom equality for state comparisons |
| onDispatch | (event) => void | Called when any action is dispatched |
| onError | (error) => void | Called when an error occurs |
Setup context:
| Property | Description |
| -------------------------- | --------------------------------------- |
| state | Reactive state (first-level props only) |
| update(fn) | Immer-style update for nested state |
| focus(path) | Create getter/setter for a path |
| get(spec) | Get dependency (store or service) |
| create(factory, ...args) | Create fresh instance |
| dirty(prop?) | Check if state has changed |
| reset() | Reset to initial state |
| onDispose(fn) | Register cleanup function |
container(options?)
Creates a container for managing store and service instances.
import { container } from "storion";
const app = container({
middleware: myMiddleware,
});
// Get store instance
const { state, actions } = app.get(userStore);
// Get service instance
const api = app.get(apiService);
// Create with parameters
const logger = app.create(loggerService, "myNamespace");
// Lifecycle
app.delete(userStore); // Remove specific instance
app.clear(); // Clear all instances
app.dispose(); // Dispose container and cleanupMethods:
| Method | Description |
| -------------------------- | ------------------------------------- |
| get(spec) | Get or create cached instance |
| create(factory, ...args) | Create fresh instance (not cached) |
| set(spec, factory) | Override factory (useful for testing) |
| delete(spec) | Remove cached instance |
| clear() | Clear all cached instances |
| dispose() | Dispose container and all instances |
effect(fn, options?)
Creates a reactive effect.
import { effect } from "storion";
const cleanup = effect((ctx) => {
console.log("Count:", state.count);
ctx.onCleanup(() => {
console.log("Cleaning up...");
});
});
// Later: stop the effect
cleanup();Context properties:
| Property | Description |
| --------------- | ------------------------------------ |
| onCleanup(fn) | Register cleanup function |
| safe(promise) | Wrap promise to ignore stale results |
| signal | AbortSignal for fetch cancellation |
| refresh() | Manually trigger re-run (async only) |
Options:
| Option | Type | Description |
| ------------ | -------- | ------------------- |
| debugLabel | string | Label for debugging |
async(focus, handler, options?)
Creates async state management.
import { async } from "storion/async";
const userAsync = async(
focus("user"),
async (ctx, userId: string) => {
const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
return res.json();
},
{
retry: { count: 3, delay: 1000 },
onError: (error) => console.error("Failed:", error),
autoCancel: true, // Cancel previous request on new dispatch (default)
}
);
// Actions
userAsync.dispatch("123"); // Start async operation
userAsync.cancel(); // Cancel current operation
userAsync.refresh(); // Refetch with same args
userAsync.reset(); // Reset to initial stateOptions:
| Option | Type | Description |
| ------------- | ------------------------------- | --------------------------------------------------------- |
| retry | number \| AsyncRetryOptions | Retry configuration |
| retry.count | number | Number of retry attempts |
| retry.delay | number \| (attempt) => number | Delay between retries (ms) |
| onError | (error) => void | Called on error |
| autoCancel | boolean | Cancel previous request on new dispatch (default: true) |
Async helpers:
// Initial state creators
async.fresh<T>(); // Fresh mode: data undefined during loading
async.stale<T>(initial); // Stale mode: preserves data during loading
// State extractors (Suspense-compatible)
async.wait(state); // Get data or throw
async.all(...states); // Wait for all, return tuple
async.any(...states); // Get first successful
async.race(states); // Get fastest
// State checks (non-throwing)
async.hasData(state); // boolean
async.isLoading(state); // boolean
async.isError(state); // boolean
// Derived state
async.derive(focus, () => {
const a = async.wait(state.a);
const b = async.wait(state.b);
return computeResult(a, b);
});How async.wait() handles each state:
| Status | Fresh Mode | Stale Mode |
| --------- | ------------------------------ | --------------------- |
| idle | ❌ Throws AsyncNotReadyError | ✅ Returns stale data |
| pending | ❌ Throws promise (Suspense) | ✅ Returns stale data |
| success | ✅ Returns data | ✅ Returns data |
| error | ❌ Throws error | ✅ Returns stale data |
Key insight: In stale mode, async.wait() always returns the stale data (even during idle/pending/error), so your UI can show previous data while loading. In fresh mode, it throws until data is ready.
// Fresh mode - throws on idle, must trigger fetch first
const freshState = async.fresh<User>();
async.wait(freshState); // ❌ Throws "Cannot wait: state is idle"
// Stale mode - returns initial data immediately
const staleState = async.stale<User[]>([]);
async.wait(staleState); // ✅ Returns [] (the initial data)async.all() follows the same rules — it calls async.wait() on each state:
// All stale mode - returns immediately with stale data
const [users, posts] = async.all(
async.stale<User[]>([]), // Returns []
async.stale<Post[]>([]) // Returns []
);
// Mixed mode - throws if any fresh state is not ready
const [user, posts] = async.all(
async.fresh<User>(), // ❌ Throws - idle fresh state
async.stale<Post[]>([])
);pick(fn, equality?)
Fine-grained value tracking.
import { pick } from "storion";
// In selector
const name = pick(() => state.profile.name);
const coords = pick(() => state.coords, "shallow");
const config = pick(() => state.config, "deep");
const custom = pick(
() => state.ids,
(a, b) => arraysEqual(a, b)
);Equality options:
| Value | Description |
| ------------------- | --------------------------------- |
| (none) | Strict equality (===) |
| "shallow" | Compare properties one level deep |
| "deep" | Recursive comparison |
| (a, b) => boolean | Custom comparison function |
batch(fn)
Batch multiple mutations into one notification.
import { batch } from "storion";
batch(() => {
state.x = 1;
state.y = 2;
state.z = 3;
});
// Subscribers notified once, not three timesuntrack(fn)
Read state without tracking dependencies.
import { untrack } from "storion";
effect(() => {
const count = state.count; // Tracked
const name = untrack(() => state.name); // Not tracked
console.log(count, name);
});
// Effect only re-runs when count changes, not when name changesAdvanced Patterns
Middleware
Middleware intercepts store creation for cross-cutting concerns.
import { container, compose, applyFor, applyExcept } from "storion";
import type { StoreMiddleware } from "storion";
// Simple middleware
const loggingMiddleware: StoreMiddleware = (ctx) => {
console.log(`Creating: ${ctx.displayName}`);
const instance = ctx.next();
console.log(`Created: ${instance.id}`);
return instance;
};
// Middleware with store-specific logic
const persistMiddleware: StoreMiddleware = (ctx) => {
const instance = ctx.next();
if (ctx.spec.options.meta?.persist) {
// Add persistence logic
}
return instance;
};
// Apply single middleware
const app = container({
middleware: loggingMiddleware,
});
// Apply multiple middlewares (array)
const app = container({
middleware: [
// Apply to stores starting with "user"
applyFor("user*", loggingMiddleware),
// Apply except to cache stores
applyExcept("*Cache", persistMiddleware),
// Apply to specific stores
applyFor(["authStore", "settingsStore"], loggingMiddleware),
// Apply based on condition
applyFor((ctx) => ctx.spec.options.meta?.debug === true, loggingMiddleware),
],
});Pattern matching:
| Pattern | Matches |
| ------------------ | ---------------------- |
| "user*" | Starts with "user" |
| "*Store" | Ends with "Store" |
| ["a", "b"] | Exact match "a" or "b" |
| (ctx) => boolean | Custom predicate |
Parameterized Services
For services that need configuration:
// Parameterized service factory
function dbService(resolver, config: { host: string; port: number }) {
return {
query: (sql: string) =>
fetch(`http://${config.host}:${config.port}/query`, {
method: "POST",
body: sql,
}),
};
}
// Use with create() instead of get()
const myStore = store({
name: "data",
state: { items: [] },
setup({ create }) {
// create() always makes a fresh instance and accepts args
const db = create(dbService, { host: "localhost", port: 5432 });
return {
fetchItems: async () => {
return db.query("SELECT * FROM items");
},
};
},
});get() vs create():
| Aspect | get() | create() |
| --------- | --------------- | -------------------- |
| Caching | Yes (singleton) | No (always fresh) |
| Arguments | None | Supports extra args |
| Use case | Shared services | Configured instances |
Mixins (Reusable Logic)
Mixins let you compose reusable logic across stores and selectors.
Store Mixin — reusable actions:
import { store, type StoreContext } from "storion";
// Define a reusable mixin
const counterMixin = (ctx: StoreContext<{ count: number }>) => ({
increment: () => ctx.state.count++,
decrement: () => ctx.state.count--,
reset: () => ctx.reset(),
});
// Use in multiple stores
const store1 = store({
name: "counter1",
state: { count: 0, label: "Counter 1" },
setup: (ctx) => ({
...ctx.mixin(counterMixin),
setLabel: (label: string) => (ctx.state.label = label),
}),
});
const store2 = store({
name: "counter2",
state: { count: 100 },
setup: (ctx) => ctx.mixin(counterMixin),
});Selector Mixin — reusable selector logic:
import { useStore, type SelectorContext } from "storion/react";
// Define a reusable selector mixin
const sumMixin = (
ctx: SelectorContext,
stores: StoreSpec<{ value: number }>[]
) => {
return stores.reduce((sum, spec) => {
const [state] = ctx.get(spec);
return sum + state.value;
}, 0);
};
function Dashboard() {
const { total } = useStore((ctx) => ({
total: ctx.mixin(sumMixin, [store1, store2, store3]),
}));
return <div>Total: {total}</div>;
}Important: Mixins are NOT singletons
Each call to mixin() creates a fresh instance. If you need singleton behavior within the same store/selector scope, wrap with memoize:
import memoize from "lodash/memoize";
import { store, type StoreContext } from "storion";
// Shared mixin - memoized to be singleton within same store setup
const sharedLogicMixin = memoize((ctx: StoreContext<any>) => {
console.log("sharedLogicMixin created"); // Only logs once per store!
return {
doSomething: () => console.log("shared logic"),
};
});
// Feature A mixin - uses sharedLogicMixin
const featureAMixin = (ctx: StoreContext<any>) => {
const shared = ctx.mixin(sharedLogicMixin); // Gets cached instance
return {
featureA: () => shared.doSomething(),
};
};
// Feature B mixin - also uses sharedLogicMixin
const featureBMixin = (ctx: StoreContext<any>) => {
const shared = ctx.mixin(sharedLogicMixin); // Gets SAME cached instance
return {
featureB: () => shared.doSomething(),
};
};
// Main store - composes both features
const myStore = store({
name: "myStore",
state: { value: 0 },
setup: (ctx) => {
const featureA = ctx.mixin(featureAMixin);
const featureB = ctx.mixin(featureBMixin);
// Both features share the same sharedLogicMixin instance!
// featureA.featureA and featureB.featureB call the same shared.doSomething
return { ...featureA, ...featureB };
},
});What happens:
featureAMixincallsmixin(sharedLogicMixin)→ creates instance, memoize caches itfeatureBMixincallsmixin(sharedLogicMixin)→ returns cached instance- Both features share the same
sharedLogicMixininstance within this store
When to use mixin vs service:
| Pattern | Caching | Access to context | Use case |
| -------------------- | ------------------------- | ---------------------- | ----------------------------------- |
| get(service) | ✅ Global singleton | ❌ No StoreContext | Shared utilities, API clients |
| mixin(fn) | ❌ Fresh each call | ✅ Full context access | Reusable actions, computed values |
| mixin(memoize(fn)) | ✅ Singleton (same scope) | ✅ Full context access | Shared logic across multiple mixins |
Equality Strategies
Storion supports equality checks at two levels, giving you fine-grained control over when updates happen.
Comparison with other libraries:
| Library | Store-level equality | Selector-level equality |
| ----------- | -------------------- | -------------------------------------- |
| Redux | ❌ No | ✅ useSelector(selector, equalityFn) |
| Zustand | ❌ No | ✅ useStore(selector, shallow) |
| Jotai | ✅ Per-atom | ❌ No |
| MobX | ✅ Deep by default | ❌ No (computed) |
| Storion | ✅ Per-property | ✅ pick(fn, equality) |
Store-level equality — Prevents notifications when state "changes" to an equivalent value:
const mapStore = store({
name: "map",
state: {
coords: { x: 0, y: 0 },
markers: [] as Marker[],
settings: { zoom: 1, rotation: 0 },
},
equality: {
// Shallow: only notify if x or y actually changed
coords: "shallow",
// Deep: recursive comparison for complex objects
settings: "deep",
// Custom function
markers: (a, b) => a.length === b.length,
},
setup({ state }) {
return {
setCoords: (x: number, y: number) => {
// This creates a new object, but shallow equality
// prevents notification if x and y are the same
state.coords = { x, y };
},
};
},
});Selector-level equality — Prevents re-renders when selected value hasn't changed:
function MapView() {
const { x, coords, markers } = useStore(({ get }) => {
const [state] = get(mapStore);
return {
// Only re-render if x specifically changed
x: pick(() => state.coords.x),
// Only re-render if coords object is shallow-different
coords: pick(() => state.coords, "shallow"),
// Custom comparison at selector level
markers: pick(
() => state.markers.map((m) => m.id),
(a, b) => a.join() === b.join()
),
};
});
}When to use each:
| Level | When it runs | Use case | | ------------------ | --------------------- | ---------------------------------------------------- | | Store-level | On every state write | Prevent unnecessary notifications to ALL subscribers | | Selector-level | On every selector run | Prevent re-renders for THIS component only |
┌─────────────────────────────────────────────────────────────────────┐
│ state.coords = { x: 1, y: 2 } │
│ │ │
│ ▼ │
│ Store-level equality: coords: "shallow" │
│ Same x and y values? → Skip notifying ALL subscribers │
│ │ │
│ ▼ (if changed) │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Component A │ │ Component B │ │
│ │ pick(() => coords.x)│ │ pick(() => coords) │ │
│ │ │ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │
│ │ Re-render if x │ │ Re-render if x OR y │ │
│ │ changed │ │ changed │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘Why Storion's approach is powerful:
// Redux/Zustand - must remember to add equality every time
const coords = useSelector((state) => state.coords, shallowEqual);
const coords = useStore((state) => state.coords, shallow);
// Storion - store-level handles common cases, pick() for fine-tuning
const mapStore = store({
equality: { coords: "shallow" }, // Set once, applies everywhere
// ...
});
// Components can add extra precision with pick()
const x = pick(() => state.coords.x); // Even finer controlTesting with Mocks
import { container } from "storion";
// Production code
const app = container();
// Test setup
const testApp = container();
// Override services with mocks
testApp.set(apiService, () => ({
get: async () => ({ id: "1", name: "Test User" }),
post: async () => ({}),
}));
// Now stores will use the mock
const { actions } = testApp.get(userStore);
await actions.fetchUser("1"); // Uses mock apiServiceChild Containers
For scoped dependencies (e.g., per-request in SSR):
const rootApp = container();
// Create child container with overrides
const requestApp = container({
parent: rootApp,
});
// Child inherits from parent but can have its own instances
requestApp.set(sessionService, () => createSessionForRequest());
// Cleanup after request
requestApp.dispose();Store Lifecycle
const myStore = store({
name: "myStore",
lifetime: "autoDispose", // Dispose when no subscribers
state: { ... },
setup({ onDispose }) {
const interval = setInterval(() => {}, 1000);
// Cleanup when store is disposed
onDispose(() => {
clearInterval(interval);
});
return { ... };
},
});Lifetime options:
| Value | Behavior |
| --------------- | ------------------------------------------- |
| "singleton" | Lives until container is disposed (default) |
| "autoDispose" | Disposed when last subscriber unsubscribes |
DevTools Integration

import { devtools } from "storion/devtools";
const app = container({
middleware: devtools({
name: "My App",
enabled: process.env.NODE_ENV === "development",
}),
});import { DevtoolsPanel } from "storion/devtools-panel";
function App() {
return (
<>
<MyApp />
{process.env.NODE_ENV === "development" && <DevtoolsPanel />}
</>
);
}Error Handling
Effect Errors
Errors in effects are caught and can be handled:
const myStore = store({
name: "myStore",
state: { ... },
onError: (error) => {
console.error("Store error:", error);
// Send to error tracking service
},
setup({ state }) {
effect(() => {
if (state.invalid) {
throw new Error("Invalid state!");
}
});
return { ... };
},
});Important: Even if an effect throws an error, it still re-runs when its tracked states change. The effect keeps its dependency tracking from before the error occurred.
effect(() => {
console.log("Effect running, count:", state.count); // Tracks `count`
if (state.count > 5) {
throw new Error("Count too high!");
}
});
// Later...
state.count = 10; // Effect re-runs, throws error, calls onError
state.count = 3; // Effect re-runs again, no error this time
state.count = 8; // Effect re-runs, throws error againThis behavior ensures that effects can recover when state returns to a valid condition.
Async Errors
const userAsync = async(
focus("user"),
async (ctx) => {
const res = await fetch("/api/user", { signal: ctx.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
{
onError: (error) => {
// Handle or log the error
},
retry: {
count: 3,
delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
},
}
);React Error Boundaries
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
);
}
function UserProfile() {
const { user } = useStore(({ get }) => {
const [state] = get(userStore);
// async.wait() throws on error, caught by ErrorBoundary
return { user: async.wait(state.currentUser) };
});
return <div>{user.name}</div>;
}Limitations & Anti-patterns
❌ Don't Mutate Nested State Directly
Direct mutation only works for first-level properties:
// ❌ Wrong - won't trigger reactivity
state.profile.name = "John";
state.items.push("new item");
// ✅ Correct - use update()
update((draft) => {
draft.profile.name = "John";
draft.items.push("new item");
});❌ Don't Call get() Inside Actions
get() is for setup-time dependencies, not runtime:
// ❌ Wrong
setup({ get }) {
return {
doSomething: () => {
const [other] = get(otherStore); // Don't do this!
},
};
}
// ✅ Correct - capture at setup time
setup({ get }) {
const [otherState, otherActions] = get(otherStore);
return {
doSomething: () => {
// Use the captured state/actions
if (otherState.ready) { ... }
},
};
}❌ Don't Use Async Effects
Effects must be synchronous:
// ❌ Wrong
effect(async (ctx) => {
const data = await fetchData();
});
// ✅ Correct
effect((ctx) => {
ctx.safe(fetchData()).then((data) => {
state.data = data;
});
});❌ Don't Pass Anonymous Functions to trigger()
Anonymous functions create new references on every render:
// ❌ Wrong - anonymous function called every render
trigger(() => {
actions.search(query);
}, [query]);
// ✅ Correct - stable function reference
trigger(actions.search, [query], query);❌ Don't Call refresh() Synchronously
Calling ctx.refresh() during effect execution throws an error:
// ❌ Wrong - throws error
effect((ctx) => {
ctx.refresh(); // Error!
});
// ✅ Correct - async or return pattern
effect((ctx) => {
setTimeout(() => ctx.refresh(), 1000);
// or
return ctx.refresh;
});❌ Don't Create Stores Inside Components
Store specs should be defined at module level:
// ❌ Wrong - creates new spec on every render
function Component() {
const myStore = store({ ... }); // Don't do this!
}
// ✅ Correct - define at module level
const myStore = store({ ... });
function Component() {
const { state } = useStore(({ get }) => get(myStore));
}❌ Don't Forget to Handle All Async States
// ❌ Incomplete - misses error and idle states
function User() {
const { user } = useStore(({ get }) => {
const [state] = get(userStore);
return { user: state.currentUser };
});
if (user.status === "pending") return <Spinner />;
return <div>{user.data.name}</div>; // Crashes if error or idle!
}
// ✅ Complete handling
function User() {
const { user } = useStore(...);
if (user.status === "idle") return <button>Load User</button>;
if (user.status === "pending") return <Spinner />;
if (user.status === "error") return <Error error={user.error} />;
return <div>{user.data.name}</div>;
}Limitation: No Deep Property Tracking
Storion tracks first-level property access, not deep paths:
// Both track "profile" property, not "profile.name"
const name1 = state.profile.name;
const name2 = state.profile.email;
// To get finer tracking, use pick()
const name = pick(() => state.profile.name);Limitation: Equality Check Timing
Store-level equality runs on write, component-level equality runs on read:
// Store level - prevents notification
store({
equality: { coords: "shallow" },
setup({ state }) {
return {
setCoords: (x, y) => {
// If same x,y, no subscribers are notified
state.coords = { x, y };
},
};
},
});
// Component level - prevents re-render
const x = pick(() => state.coords.x);
// Component only re-renders if x specifically changedContributing
Prerequisites
- Node.js 18+
- pnpm 8+
Setup
git clone https://github.com/linq2js/storion.git
cd storion
pnpm install
pnpm --filter storion buildDevelopment
pnpm --filter storion dev # Watch mode
pnpm --filter storion test # Run tests
pnpm --filter storion test:ui # Tests with UICommit Messages
Use Conventional Commits:
feat(core): add new feature
fix(react): resolve hook issue
docs: update READMEAI Assistance
For AI coding assistants, see AI_GUIDE.md for rules and patterns when generating Storion code.
License
MIT © linq2js
