Skip to content

@stateloom/store

Store-based state management. If you've used Zustand or Redux Toolkit, this API will feel immediately familiar.

Install

bash
pnpm add @stateloom/core @stateloom/store
bash
npm install @stateloom/core @stateloom/store
bash
yarn add @stateloom/core @stateloom/store

Size: ~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

typescript
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:

typescript
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:

typescript
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:

tsx
// 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:

typescript
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:

typescript
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:

ParameterTypeDescriptionDefault
creatorStateCreator<T>Factory function that receives (set, get) and returns the initial state object.--
optionsStoreOptions<T> | undefinedOptional configuration.undefined
options.middlewarereadonly Middleware<T>[]Middleware pipeline applied to the store.[]

Returns: StoreApi<T> -- the store API for reading, writing, and subscribing to state.

typescript
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; // 1

Key behaviors:

  • setState shallow-merges via Object.assign({}, currentState, partial) -- a new object reference is always created
  • The get() method integrates with the reactive graph -- reads inside computed() or effect() are automatically tracked
  • Middleware onSet hooks form a chain; each must call next(partial) to pass the update through
  • Calling setState on a destroyed store is a no-op
  • setState during 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.

typescript
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;
}
MethodDescription
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.

typescript
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.

typescript
type SetStateFn<T> = (partial: Partial<T> | ((state: T) => Partial<T>)) => void;

Listener<T>

Subscriber callback for store state changes.

typescript
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.

typescript
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;
}
HookWhenRequired Action
initOnce after the store is fully initialized--
onSetOn each setState call (resolved partial)Must call next(partial) to propagate
onGetReserved for future use--
onSubscribeWhen a listener is registeredReturn a (possibly wrapped) listener
onDestroyWhen the store is destroyedPerform cleanup
typescript
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.

typescript
interface MiddlewareAPI<T> {
  getState(): T;
  setState: SetStateFn<T>;
  getInitialState(): T;
  subscribe(listener: Listener<T>): () => void;
}

StoreOptions<T> (interface)

Options for createStore.

typescript
interface StoreOptions<T> {
  readonly middleware?: readonly Middleware<T>[];
}
PropertyTypeDescriptionDefault
middlewarereadonly Middleware<T>[] | undefinedMiddleware 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.

typescript
type SetFn<T> = (partial: Partial<T>) => void;

Selectors

Selectors derive slices of state. In framework adapters, they produce memoized computeds:

typescript
// 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 changes

Patterns

Sliced Stores

Split large stores into slices for organization:

typescript
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:

typescript
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:

typescript
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:

  1. middleware[0].onSet(api, next, partial) -- next calls middleware[1]
  2. middleware[1].onSet(api, next, partial) -- next calls the actual setState
  3. Actual setState -- merges state and notifies subscribers

TypeScript

Types are inferred automatically from the creator function:

typescript
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-locatedState is composed bottom-upYou want mutable syntax
You have a single state objectYou have many independent valuesYou have deeply nested objects
You're coming from Zustand/ReduxYou're coming from Jotai/RecoilYou're coming from Valtio/MobX
Middleware is needed (persist, devtools)Suspense integration is neededRapid prototyping