@stateloom/store
Store-based state management. If you've used Zustand or Redux Toolkit, this API will feel immediately familiar.
Install
pnpm add @stateloom/core @stateloom/storenpm install @stateloom/core @stateloom/storeyarn add @stateloom/core @stateloom/storeSize: ~0.5 KB gzipped (+ core)
Overview
A store is a single object holding related state and actions. It wraps a core signal internally — all the reactive guarantees (glitch-free updates, batching, scoping) carry over automatically.
Quick Start
import { createStore } from '@stateloom/store';
const counterStore = createStore((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Vanilla usage
counterStore.getState().count; // 0
counterStore.getState().increment();
counterStore.getState().count; // 1
// Subscribe to changes
const unsubscribe = counterStore.subscribe((state) => {
console.log('Count changed:', state.count);
});Guide
Creating a Store
A store combines state and actions in a single object. The creator function receives set and get:
import { createStore } from '@stateloom/store';
const todoStore = createStore((set, get) => ({
// State
todos: [] as { id: string; text: string; done: boolean }[],
// Actions
addTodo: (text: string) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
})),
toggleTodo: (id: string) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
// Read current state via get()
getRemainingCount: () => get().todos.filter((t) => !t.done).length,
}));Subscribing to Changes
Use subscribe() to listen for state changes. The listener receives both the new state and the previous state:
const unsubscribe = todoStore.subscribe((state, prevState) => {
if (state.todos.length !== prevState.todos.length) {
console.log('Todo count changed:', state.todos.length);
}
});
// Stop listening
unsubscribe();Using with Framework Adapters
Each framework adapter provides a hook for store integration:
// React
import { useStore } from '@stateloom/react';
function TodoCount() {
const count = useStore(todoStore, (s) => s.todos.length);
return <span>{count} todos</span>;
}Adding Middleware
Middleware intercepts state operations in a pipeline. Order matters -- middleware executes left-to-right:
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';
const store = createStore(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
middleware: [devtools({ name: 'Counter' }), persist({ key: 'counter' })],
},
);Advanced: Async Actions
Actions can be async. Use get() to read the latest state:
const store = createStore((set, get) => ({
data: null as Data | null,
loading: false,
fetch: async () => {
set({ loading: true });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, loading: false });
} catch {
set({ loading: false });
}
},
}));API Reference
createStore<T>(creator: StateCreator<T>, options?: StoreOptions<T>): StoreApi<T>
Create a store with co-located state and actions. The store wraps a core signal internally -- all reactive guarantees (glitch-free updates, batching, scoping) carry over automatically.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
creator | StateCreator<T> | Factory function that receives (set, get) and returns the initial state object. | -- |
options | StoreOptions<T> | undefined | Optional configuration. | undefined |
options.middleware | readonly Middleware<T>[] | Middleware pipeline applied to the store. | [] |
Returns: StoreApi<T> -- the store API for reading, writing, and subscribing to state.
import { createStore } from '@stateloom/store';
const store = createStore(
(set, get) => ({
count: 0,
user: null as User | null,
increment: () => set((state) => ({ count: state.count + 1 })),
setUser: (user: User) => set({ user }),
getDisplayName: () => get().user?.name ?? 'Anonymous',
}),
{
middleware: [
/* optional middleware */
],
},
);
store.getState().count; // 0
store.getState().increment();
store.getState().count; // 1Key behaviors:
setStateshallow-merges viaObject.assign({}, currentState, partial)-- a new object reference is always created- The
get()method integrates with the reactive graph -- reads insidecomputed()oreffect()are automatically tracked - Middleware
onSethooks form a chain; each must callnext(partial)to pass the update through - Calling
setStateon a destroyed store is a no-op setStateduring the creator function bypasses middleware (the chain is not yet built)
See also: StoreApi<T>, Middleware<T>, StateCreator<T>
StoreApi<T> (interface)
Public API of a store created by createStore. Extends Subscribable<T> from @stateloom/core, so stores work with any framework adapter.
interface StoreApi<T> extends Subscribable<T> {
get(): T;
getState(): T;
setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
subscribe(listener: (state: T, prevState: T) => void): () => void;
getInitialState(): T;
destroy(): void;
}| Method | Description |
|---|---|
get() | Read current state. Triggers reactive tracking inside computed() or effect(). |
getState() | Alias for get(). |
setState(partial) | Update state by shallow-merging a partial object or updater function. |
subscribe(listener) | Register a (state, prevState) callback. Returns an unsubscribe function. |
getInitialState() | Read the initial state returned by the creator function. Never changes. |
destroy() | Destroy the store: clears listeners, invokes middleware onDestroy, disconnects from the reactive graph. Subsequent setState calls become no-ops. |
StateCreator<T>
Creator function passed to createStore. Receives set and get functions and returns the initial state object.
type StateCreator<T> = (set: SetStateFn<T>, get: GetStateFn<T>) => T;set(partial)-- shallow-merge a partial state object:set({ count: 1 })set(updater)-- function update:set((state) => ({ count: state.count + 1 }))get()-- read current state synchronously
SetStateFn<T>
Public set function type. Accepts a partial object or an updater function.
type SetStateFn<T> = (partial: Partial<T> | ((state: T) => Partial<T>)) => void;Listener<T>
Subscriber callback for store state changes.
type Listener<T> = (state: T, prevState: T) => void;Middleware<T> (interface)
Middleware interface for cross-cutting store concerns. Hooks intercept store lifecycle events: state writes, subscriptions, initialization, and destruction.
interface Middleware<T> {
readonly name: string;
readonly init?: (api: MiddlewareAPI<T>) => void;
readonly onSet?: (api: MiddlewareAPI<T>, next: SetFn<T>, partial: Partial<T>) => void;
readonly onGet?: (api: MiddlewareAPI<T>, key: keyof T) => void;
readonly onSubscribe?: (api: MiddlewareAPI<T>, listener: Listener<T>) => Listener<T>;
readonly onDestroy?: (api: MiddlewareAPI<T>) => void;
}| Hook | When | Required Action |
|---|---|---|
init | Once after the store is fully initialized | -- |
onSet | On each setState call (resolved partial) | Must call next(partial) to propagate |
onGet | Reserved for future use | -- |
onSubscribe | When a listener is registered | Return a (possibly wrapped) listener |
onDestroy | When the store is destroyed | Perform cleanup |
import type { Middleware } from '@stateloom/store';
const logger: Middleware<{ count: number }> = {
name: 'logger',
onSet: (api, next, partial) => {
console.log('prev:', api.getState());
next(partial);
console.log('next:', api.getState());
},
};WARNING
A middleware onSet handler must call next(partial) to pass the update through the chain. Omitting the call blocks the state update entirely.
See also: MiddlewareAPI<T>, createStore()
MiddlewareAPI<T> (interface)
API surface exposed to middleware hooks. Provides read and write access to the store plus subscription capability.
interface MiddlewareAPI<T> {
getState(): T;
setState: SetStateFn<T>;
getInitialState(): T;
subscribe(listener: Listener<T>): () => void;
}StoreOptions<T> (interface)
Options for createStore.
interface StoreOptions<T> {
readonly middleware?: readonly Middleware<T>[];
}| Property | Type | Description | Default |
|---|---|---|---|
middleware | readonly Middleware<T>[] | undefined | Middleware pipeline applied to the store. First middleware is outermost. | [] |
SetFn<T>
Internal set function used within the middleware chain. Unlike SetStateFn, always receives a resolved Partial<T> -- updater functions are resolved before entering the chain.
type SetFn<T> = (partial: Partial<T>) => void;Selectors
Selectors derive slices of state. In framework adapters, they produce memoized computeds:
// Vanilla -- manual selector
const count = store.getState().count;
// React -- auto-memoized via computed internally
const count = useStore(counterStore, (state) => state.count);
// Only re-renders when `count` changes, not when other state changesPatterns
Sliced Stores
Split large stores into slices for organization:
import { createStore } from '@stateloom/store';
import type { SetStateFn, GetStateFn } from '@stateloom/store';
type StoreState = CountSlice & UserSlice;
interface CountSlice {
count: number;
increment: () => void;
}
interface UserSlice {
user: User | null;
setUser: (user: User) => void;
}
const createCountSlice = (
set: SetStateFn<StoreState>,
get: GetStateFn<StoreState>,
): CountSlice => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
});
const createUserSlice = (set: SetStateFn<StoreState>, get: GetStateFn<StoreState>): UserSlice => ({
user: null,
setUser: (user: User) => set({ user }),
});
const store = createStore<StoreState>((set, get) => ({
...createCountSlice(set, get),
...createUserSlice(set, get),
}));Computed Values
Embed computed values using the get parameter:
import { createStore } from '@stateloom/store';
import { computed } from '@stateloom/core';
const store = createStore((set, get) => ({
items: [] as Item[],
filter: 'all' as 'all' | 'active' | 'done',
// Computed: auto-memoized, only recomputes when items or filter change
filteredItems: computed(() => {
const { items, filter } = get();
if (filter === 'all') return items;
return items.filter((item) => (filter === 'active' ? !item.done : item.done));
}),
}));Async Actions
Actions can be async. Use get() to read latest state and set() to update:
import { createStore } from '@stateloom/store';
const store = createStore((set, get) => ({
data: null as Data | null,
loading: false,
error: null as string | null,
fetch: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, loading: false });
} catch (err) {
set({ error: (err as Error).message, loading: false });
}
},
}));How It Works
Internal Architecture
A store wraps a core signal to hold its state. All reactive guarantees (glitch-free updates, batching, scoping) carry over automatically:
Shallow Merge Semantics
setState performs a shallow merge via Object.assign({}, currentState, partial). This means:
- Top-level keys are replaced, not deep-merged
- Missing keys in the partial are retained from the current state
- Functions (actions) in the partial overwrite existing ones
Middleware Pipeline
Middleware wraps the onSet hook. Each middleware's onSet receives the previous middleware's next function, forming a chain:
middleware[0].onSet(api, next, partial)--nextcalls middleware[1]middleware[1].onSet(api, next, partial)--nextcalls the actualsetState- Actual
setState-- merges state and notifies subscribers
TypeScript
Types are inferred automatically from the creator function:
import { createStore } from '@stateloom/store';
import { expectTypeOf } from 'vitest';
// Type is inferred from the creator return type
const store = createStore((set, get) => ({
count: 0,
name: 'Alice',
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// State type inferred
expectTypeOf(store.getState().count).toEqualTypeOf<number>();
expectTypeOf(store.getState().name).toEqualTypeOf<string>();
expectTypeOf(store.getState().increment).toEqualTypeOf<() => void>();
// setState accepts partial or updater
store.setState({ count: 1 }); // partial object
store.setState((state) => ({ count: state.count + 1 })); // updater function
// Subscribe receives (state, prevState)
store.subscribe((state, prevState) => {
expectTypeOf(state).toEqualTypeOf<typeof prevState>();
});
// Explicit generic for complex types
interface AppState {
user: User | null;
loading: boolean;
setUser: (user: User) => void;
}
const appStore = createStore<AppState>((set) => ({
user: null,
loading: false,
setUser: (user) => set({ user }),
}));When to Use Store vs Atom vs Proxy
| Use Store when... | Use Atom when... | Use Proxy when... |
|---|---|---|
| State and actions are co-located | State is composed bottom-up | You want mutable syntax |
| You have a single state object | You have many independent values | You have deeply nested objects |
| You're coming from Zustand/Redux | You're coming from Jotai/Recoil | You're coming from Valtio/MobX |
| Middleware is needed (persist, devtools) | Suspense integration is needed | Rapid prototyping |