state
bun add @stopcock/stateA store holds your state. You read and write slices with plain accessor functions like s => s.user.name. Subscriptions only fire when the slice you’re watching actually changes. Every mutation produces a Patch (from @stopcock/diff), so middleware can inspect, transform, or reject changes before they land.
import { pipe } from '@stopcock/fp'import { create, computed } from '@stopcock/state'
const store = create({ user: { name: 'Tom' }, todos: [] as Todo[] })
store.subscribe(s => s.user.name, (next, prev) => { console.log(`${prev} -> ${next}`)})
store.set(s => s.user.name, 'Alice') // logs "Tom -> Alice"store.set(s => s.todos, []) // subscriber above doesn't fireHow it works
Section titled “How it works”When you pass s => s.user.name to a store method, the store runs that function against a Proxy. The Proxy records which properties were accessed and builds a path: ['user', 'name']. That path is compiled into a lens (from @stopcock/fp) and cached. Next time you pass the same function, it hits the cache.
This means:
- No string paths. No action constants. Just functions.
- TypeScript validates the accessor. If the property doesn’t exist, you get a type error.
- The path is extracted once and reused for reads, writes, and subscription matching.
When you call set or over, the store constructs a replace patch directly from the compiled path. No diffing. The lens produces the next state, and the patch tells each subscriber whether its slice was affected. update uses a recording proxy that captures your mutations as patch ops without cloning or diffing. Only replace() does a full structural diff, since it swaps the entire state and needs to figure out what changed.
Why this over Zustand / Redux / Jotai?
Section titled “Why this over Zustand / Redux / Jotai?”Every mutation produces a Patch. That’s the core difference. The store doesn’t just update state and re-run selectors. It knows exactly what changed, as structured operations, and routes those operations through middleware before applying them.
For set and over, the store constructs the patch directly from the accessor path. No diffing. For update and replace, where you mutate a draft or swap the whole tree, it diffs to figure out what you touched. Either way, middleware sees the same thing: a patch it can inspect, transform, or reject.
That means undo/redo, logging, devtools, and change rejection aren’t bolted-on features. They’re just middleware that reads the patch. history() records patches and inverts them. logger() prints them. devtools() sends them to Redux DevTools. You can write your own in a few lines. When there’s no middleware, the store skips that step entirely.
Other things that fall out of this:
- Subscriptions skip work. Each subscriber declares a path (
s => s.user.name). Subscribers are indexed by their root key, so when a patch touchesuser, only subscribers underuserare checked. The rest aren’t iterated at all. Within that set, the store checks path overlap before comparing values. Zustand and Redux re-run every selector on every update. Jotai avoids this with atoms, but you need an atom per value. - No spread nesting.
store.set(s => s.a.b.c, value)handles the immutable update for you. No{ ...state, a: { ...state.a, b: { ...state.b } } }. - Batching is transactional. Writes inside a
batchaccumulate patches and compose them at the end into a single notification. If the callback throws, state rolls back to where it was before the batch started. - Patches are data. You can serialize them, send them over the wire, compose them, rebase concurrent edits. The whole
@stopcock/diffpackage is the foundation.
Real-world patterns
Section titled “Real-world patterns”Todo app
Section titled “Todo app”import { create, computed, history } from '@stopcock/state'
type State = { todos: { id: number; text: string; done: boolean }[] filter: 'all' | 'active' | 'done'}
const h = history<State>()const store = create<State>( { todos: [], filter: 'all' }, { middleware: [h.middleware] },)
const visible = computed( store, s => s.todos, todos => todos.filter(t => !t.done).length,)
// Add a todostore.over(s => s.todos, todos => [ ...todos, { id: Date.now(), text: 'Write docs', done: false },])
// Togglestore.update(s => s.todos[0], draft => { draft.done = !draft.done })
// Undo the toggleh.undo(store)Form state
Section titled “Form state”const form = create({ fields: { email: '', password: '' }, errors: {} as Record<string, string>, submitting: false,})
form.subscribe(s => s.fields, (fields) => { // re-validate on any field change const errors: Record<string, string> = {} if (!fields.email.includes('@')) errors.email = 'Invalid email' if (fields.password.length < 8) errors.password = 'Too short' form.set(s => s.errors, errors)})
form.set(s => s.fields.email, 'tom@example.com')Async data with resources
Section titled “Async data with resources”import { create, resource, mutation } from '@stopcock/state'import { createClient } from '@stopcock/http'
const api = createClient({ baseUrl: '/api' })const store = create({ selectedUserId: null as number | null })
const users = resource({ fetch: (signal) => api.get<User[]>('/users', { signal }),})
const userPosts = resource({ deps: (get) => { const id = get(store, s => s.selectedUserId) if (id === null) return null return { userId: id } }, fetch: ({ userId }, signal) => api.get<Post[]>('/posts', { query: { userId }, signal }),})
const addPost = mutation({ fn: (input: { userId: number; title: string }, signal) => api.post<Post>('/posts', { body: input, signal }), invalidates: [userPosts],})
await addPost.run({ userId: 1, title: 'Hello' })Batching
Section titled “Batching”Multiple writes in a batch produce a single notification.
store.batch(() => { store.set(s => s.user.name, 'Alice') store.set(s => s.user.age, 31)})// subscribers notified once with the final stateIntermediate get() calls inside a batch see the updated state. If the callback throws, the state rolls back to where it was before the batch. Nested batches are fine. Only the outermost one triggers notifications.
Middleware
Section titled “Middleware”Every state change produces a Patch (from @stopcock/diff). Middleware intercepts that patch before it’s applied.
type Middleware<S> = (patch: Patch, state: S) => Patch | nullReturn the patch to let it through, transform it, or return null to reject it.
logger
Section titled “logger”import { create, logger } from '@stopcock/state'
const store = create(initial, { middleware: [logger({ collapsed: true })],})Logs every change to the console with color-coded operation types, formatted paths, and grouped output.
history (undo/redo)
Section titled “history (undo/redo)”Built on patch inversion. Every change is recorded. undo() inverts the last patch and applies it backwards. redo() replays the forward patch.
import { create, history } from '@stopcock/state'
const h = history<State>()const store = create(initial, { middleware: [h.middleware] })
store.set(s => s.count, 1)store.set(s => s.count, 2)
h.canUndo // trueh.undo(store) // count back to 1h.redo(store) // count back to 2Making a new change after undo clears the redo stack.
devtools
Section titled “devtools”Connects to the Redux DevTools browser extension via the onCommit hook. The store emits the patch it already computed, so devtools sees every change without re-diffing.
import { create, withDevtools } from '@stopcock/state'
const opts = withDevtools<State>('MyStore')const store = create(initial, opts)opts.connect(store)Or wire it up manually if you need more control:
import { create, devtools } from '@stopcock/state'
const dt = devtools<State>('MyStore')const store = create(initial, { onCommit: dt.onCommit })dt.connect(store)API reference
Section titled “API reference”create<S extends object>(initial: S, options?: StoreOptions<S>): Store<S>interface Store<S> { get(): S get<A>(accessor: Accessor<S, A>): A set<A>(accessor: Accessor<S, A>, value: A): void over<A>(accessor: Accessor<S, A>, fn: (a: A) => A): void update(fn: (draft: S) => void): void update<A>(accessor: Accessor<S, A>, fn: (draft: A) => void): void replace(next: S): void merge(partial: Partial<S>): void batch(fn: () => void): void at<A>(path: readonly (string | number)[]): Handle<A> subscribe(listener: Listener<S>): Unsubscribe subscribe<A>(accessor: Accessor<S, A>, listener: Listener<A>): Unsubscribe destroy(): void}Computed
Section titled “Computed”computed<S, A, D>( store: Store<S>, accessor: Accessor<S, A>, derive: (slice: A) => D, eq?: (a: D, b: D) => boolean,): Computed<D>Derived state. Computes the initial value eagerly, then caches it. Only re-derives when the source slice changes. Custom equality function gates notifications.
interface Computed<D> { get(): D subscribe(listener: Listener<D>): Unsubscribe destroy(): void}Middleware
Section titled “Middleware”logger<S>(options?: { collapsed?: boolean; table?: boolean }): Middleware<S>history<S>(options?: { maxDepth?: number }): History<S>devtools<S>(options?: string | { name?: string; debounce?: number }): { onCommit: OnCommit<S>; connect: (store: Store<S>) => void }withDevtools<S>(options?: string | DevtoolsOptions, base?: StoreOptions<S>): StoreOptions<S> & { connect: (store: Store<S>) => void }composeMiddleware<S>(...mws: Middleware<S>[]): Middleware<S>Resource
Section titled “Resource”Reactive async data fetching. Declares what to fetch and what it depends on. Refetches automatically when dependencies change.
import { resource, idle } from '@stopcock/state'
// standaloneconst users = resource({ fetch: (signal) => fetch('/api/users', { signal }).then(r => r.json()),})
// with store deps. refetches when search changesconst filtered = resource({ deps: (get) => ({ q: get(store, s => s.search) }), fetch: ({ q }, signal) => fetch(`/api/users?q=${q}`, { signal }).then(r => r.json()),})
// with resource deps. waits for parent to resolveconst posts = resource({ deps: (get) => { const user = get(selectedUser) if (!user) return null // don't fetch yet return { userId: user.id } }, fetch: ({ userId }, signal) => fetch(`/api/posts?userId=${userId}`, { signal }).then(r => r.json()),})deps returns null to pause fetching. When the dependency that caused null changes, deps re-runs. No enabled flag needed.
Rapid dependency changes are coalesced via microtask. The resource aborts in-flight requests on dep change and uses a generation counter to discard stale responses.
resource({ fetch: (signal) => fetchUsers(signal), initialData: [], // skip loading state on first render lazy: true, // don't fetch until first subscriber staleTime: 30_000, // skip refetch if data is fresh refetchInterval: 60_000, // poll every 60s refetchOnFocus: true, // refetch when tab becomes visible refetchOnReconnect: true, // refetch when network reconnects retry: 3, // retry failed fetches retryDelay: (n) => Math.min(1000 * 2 ** n, 30_000), into: store.at(['users']), // mirror state into a store slot})interface Resource<T> { get(): ResourceState<T> readonly data: T | undefined readonly status: 'idle' | 'loading' | 'ok' | 'error' readonly error: unknown readonly isLoading: boolean readonly isOk: boolean readonly isError: boolean readonly isIdle: boolean refetch(): void abort(): void update(fn: (prev: T | undefined) => T): void subscribe(listener): Unsubscribe destroy(): void}Mutation
Section titled “Mutation”Imperative async writes. Invalidates resources on success. Supports optimistic updates.
import { mutation } from '@stopcock/state'
const addUser = mutation({ fn: (input: { name: string }, signal) => fetch('/api/users', { method: 'POST', body: JSON.stringify(input), signal }).then(r => r.json()), invalidates: [users], optimistic: (input) => { users.update(prev => [...(prev ?? []), { ...input, id: 'temp' }]) },})
await addUser.run({ name: 'Alice' })On success, all resources in invalidates refetch. If optimistic is set and the mutation fails, the invalidated resources refetch to revert to server state.
interface Mutation<I, O> { run(input: I): Promise<O> get(): MutationState<O> readonly state: MutationState<O> readonly isRunning: boolean readonly error: unknown abort(): void reset(): void subscribe(listener): Unsubscribe}import { useStore, useResource, useMutation } from '@stopcock/state/react'
function UserList() { const search = useStore(store, s => s.search) const { data, isLoading, refetch } = useResource(users) const { status, run } = useMutation(addUser) // ...}useResource re-renders only when the resource state changes. useMutation re-renders on status transitions (idle -> running -> ok/error). Both use useSyncExternalStore.
type Accessor<S, A> = (state: S) => Atype Listener<A> = (next: A, prev: A) => voidtype Middleware<S> = (patch: Patch, state: S) => Patch | nulltype Unsubscribe = () => void
interface Handle<A> { get(): A set(value: A): void over(fn: (a: A) => A): void subscribe(listener: Listener<A>): Unsubscribe}