Skip to content

state

Terminal window
bun add @stopcock/state

A 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 fire

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.

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 touches user, only subscribers under user are 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 batch accumulate 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/diff package is the foundation.
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 todo
store.over(s => s.todos, todos => [
...todos, { id: Date.now(), text: 'Write docs', done: false },
])
// Toggle
store.update(s => s.todos[0], draft => { draft.done = !draft.done })
// Undo the toggle
h.undo(store)
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')
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' })

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 state

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

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 | null

Return the patch to let it through, transform it, or return null to reject it.

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.

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 // true
h.undo(store) // count back to 1
h.redo(store) // count back to 2

Making a new change after undo clears the redo stack.

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)

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<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
}
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>

Reactive async data fetching. Declares what to fetch and what it depends on. Refetches automatically when dependencies change.

import { resource, idle } from '@stopcock/state'
// standalone
const users = resource({
fetch: (signal) => fetch('/api/users', { signal }).then(r => r.json()),
})
// with store deps. refetches when search changes
const 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 resolve
const 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
}

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) => A
type Listener<A> = (next: A, prev: A) => void
type Middleware<S> = (patch: Patch, state: S) => Patch | null
type Unsubscribe = () => void
interface Handle<A> {
get(): A
set(value: A): void
over(fn: (a: A) => A): void
subscribe(listener: Listener<A>): Unsubscribe
}