exostate
v1.1.0
Published
Type-safe state management for React, Vue, Svelte, Solid and vanilla JS — with a built-in async query cache (stale-while-revalidate, request deduplication, retries, SSR hydration), plugins, time-travel history, persistence and transactions. A Zustand + Ta
Maintainers
Keywords
Readme
exostate
The two-library problem
Every serious frontend app installs two state libraries and glues them together:
# Client state
npm install zustand
# Server state
npm install @tanstack/react-query
# ...then discover they don't share a cache
# ...then write the bridge code yourself
# ...then repeat all of it for your Vue admin panelExostate is one package, one mental model, every framework:
npm install exostateimport { createStore, QueryClient } from 'exostate'
// Client state — synchronous, immutable, type-safe
const ui = createStore({ theme: 'dark', sidebarOpen: false })
ui.patch({ sidebarOpen: true })
// Server state — cached, deduplicated, revalidated
const client = new QueryClient()
const user = await client.fetchQuery({
queryKey: ['user', 42],
queryFn: ({ signal }) => fetch('/api/users/42', { signal }).then(r => r.json()),
staleTime: 30_000,
})Same store primitive underneath. Same subscription model. Works in React, Vue, Svelte, Solid, and plain JavaScript — including on the server.
Table of Contents
- Why Exostate
- Feature comparison
- Bundle size
- Performance
- Installation
- Quick start
- Core concepts
- The query layer
- Framework adapters
- Advanced features
- Recipes
- API reference
- FAQ
- Contributing
- License
Why Exostate
One package instead of two. Client state and server state use the same store, the same subscription model, and the same types. No bridge code.
Genuinely framework-agnostic. The core has zero framework imports. The React, Vue, Svelte, and Solid adapters are thin — under 1 kB each — and every feature works in plain JavaScript and on Node.
Zero runtime dependencies. Nothing is pulled into your lockfile.
Tree-shakeable by design. Importing createStore costs 1.16 kB gzipped.
The query layer only ships if you import it.
Immutable and type-safe. State is DeepReadonly at the type level;
mutating methods return new values. No proxies, no magic, no any.
Browser-safe core. Node-only code lives in exostate/node, so the main
entry never drags node:fs into a browser bundle. CI enforces this.
Feature comparison
| Feature | Exostate | Zustand | Jotai | Nanostores | Redux Toolkit | TanStack Query | | --- | :---: | :---: | :---: | :---: | :---: | :---: | | Client state | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | Async query cache | ✅ | ❌ | partial | ❌ | RTK Query | ✅ | | Stale-while-revalidate | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | Request deduplication | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | Optimistic updates + rollback | ✅ | manual | manual | manual | ✅ | ✅ | | Cache garbage collection | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | SSR dehydrate / hydrate | ✅ | manual | manual | manual | ✅ | ✅ | | React adapter | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Vue adapter | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | | Svelte adapter | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | | Solid adapter | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | | Vanilla JS | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | | Transactions with rollback | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | Undo / redo history | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | Event sourcing / audit log | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | Plugin system | ✅ | middleware | ❌ | ❌ | middleware | ❌ | | Lazy mount/unmount lifecycle | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | | Microtask batching | ✅ | ❌ | ✅ | ❌ | ❌ | n/a | | IndexedDB persistence | ✅ | plugin | plugin | plugin | plugin | ❌ | | Redux DevTools | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | | Runtime dependencies | 0 | 0 | 0 | 0 | 3+ | 0 |
Comparison reflects each library's core package without third-party plugins, as of August 2026. "manual" means achievable but not provided.
Bundle size
Measured with size-limit on real esbuild-bundled, minified, gzipped output —
not on the un-bundled barrel file. Reproduce with npm run size.
| What you import | Gzipped |
| --- | ---: |
| createStore only | 1.16 kB |
| createStore + computed + persistLocal + createHistory | 1.92 kB |
| Query layer (QueryClient + createMutation) | 3.84 kB |
| React adapter (all hooks) | 913 B |
| React query hooks | 2.37 kB |
| Entire library, nothing tree-shaken | 7.72 kB |
Because the package is side-effect free and every feature is a separate export, you only pay for what you import — a counter store costs 1.16 kB whether or not the query layer exists in the package.
Performance
Run on Node 20, 100,000 iterations. Reproduce with npm run bench:compare.
These are micro-benchmarks — in real apps, render behaviour dominates.
Updates, no subscribers
| Library | Operation | Ops/sec |
| --- | --- | ---: |
| Exostate | patch / assign | 19,833,891 |
| Zustand | setState | 14,755,883 |
| Exostate | update(reducer) | 7,648,695 |
| Redux | dispatch | 5,273,404 |
Updates with one subscriber
| Library | Operation | Ops/sec |
| --- | --- | ---: |
| Exostate | patch / assign | 16,114,630 |
| Zustand | setState | 14,683,389 |
| Exostate | update(reducer) | 6,941,873 |
| Redux | dispatch | 4,982,034 |
Read honestly: Exostate's shallow-merge path is the fastest of the four, and
its reducer path beats Redux but trails Zustand's setState — reducers do
strictly more work. Pick patch for hot paths and update when you want the
reducer discipline.
Installation
npm install exostate
# or
pnpm add exostate
# or
yarn add exostate
# or
bun add exostateFramework packages are optional peer dependencies — install only what you use.
| Entry point | Import from | Requires |
| --- | --- | --- |
| Core (framework-agnostic) | exostate | — |
| React hooks | exostate/react | react >= 18 |
| React query hooks | exostate/react/query | react >= 18 |
| Vue composables | exostate/vue | vue >= 3 |
| Svelte stores | exostate/svelte | svelte >= 4 |
| Solid signals | exostate/solid | solid-js >= 1 |
| Filesystem persistence | exostate/node | Node >= 18 |
Requires TypeScript 5.0+ for the bundled types. Runs on Node 18+ — verified in CI against the built package on Node 18 itself. (Contributing to the repo needs Node 20+, since the test toolchain does.)
Quick start
import { createStore } from 'exostate'
interface CartState {
items: Array<{ id: string; qty: number }>
coupon: string | null
}
const cart = createStore<CartState>({ items: [], coupon: null })
// Read
cart.read() // CartState
cart.snapshot() // DeepReadonly<CartState>
// Write — shallow merge, Zustand-style
cart.patch({ coupon: 'SUMMER25' })
// Write — functional
cart.patch(prev => ({ items: [...prev.items, { id: 'sku-1', qty: 1 }] }))
// Write — with a reducer, for logic you want named and testable
const addItem = (prev: CartState, item: { id: string; qty: number }) => ({
...prev,
items: [...prev.items, item],
})
cart.update(addItem, { id: 'sku-2', qty: 3 })
// Subscribe to a slice — only fires when that slice changes
const unsubscribe = cart.subscribe(
s => s.items.length,
count => console.log('item count:', count)
)
unsubscribe()Core concepts
Creating a store
import { createStore } from 'exostate'
const store = createStore({ count: 0 })With options:
const store = createStore(
{ count: 0 },
{
notify: 'microtask', // coalesce notifications — see below
unmountDelay: 1000, // grace period before onUnsubscribe fires
plugins: [logger()], // attach plugins at construction
onSubscribe: (s, listenerCount) => { /* … */ },
onUnsubscribe: (s, listenerCount) => { /* … */ },
}
)Updating state
Every mutating method returns the new state and goes through a single commit path, so plugins and batching can never be bypassed.
| Method | Use for |
| --- | --- |
| patch(partial) | Shallow-merge an object or prev => partial |
| set(next) | Replace the whole state |
| update(reducer, payload) | Named, testable transitions |
| compute(fn) | prev => next without a payload |
| batch(apply) | Several reducers, one notification |
| effect(fn, payload) | Read-only side effects |
store.patch({ count: 5 })
store.patch(prev => ({ count: prev.count + 1 }))
store.set({ count: 0 })
store.update((prev, by: number) => ({ count: prev.count + by }), 10)
store.compute(prev => ({ count: prev.count * 2 }))
// One notification for the whole group
store.batch(apply => {
apply((prev, by: number) => ({ count: prev.count + by }), 1)
apply((prev, by: number) => ({ count: prev.count * by }), 3)
})
patchperforms a shallow merge. Nested objects are replaced, not merged — the same rule Zustand uses, chosen because it is predictable.
Subscribing and selectors
const unsubscribe = store.subscribe(
s => s.user.name, // selector — subscription is scoped to this
name => console.log(name), // only called when the selected value changes
{ fireImmediately: true } // optional: call once with the current value
)Pass a custom comparator when the selector builds a new object each call:
import { shallow, deepEqual } from 'exostate'
store.subscribe(
s => ({ id: s.user.id, name: s.user.name }),
user => render(user),
{ eq: shallow }
)Computed values
computed caches against the store's version counter, so the selector runs at
most once per state change no matter how often you read it.
import { computed } from 'exostate'
const fullName = computed(userStore, s => `${s.firstName} ${s.lastName}`)
fullName.read() // computes
fullName.read() // cached — no recomputation
fullName.subscribe(name => console.log(name))Combining stores
import { combineStores } from 'exostate'
const app = combineStores({ cart, user, ui })
app.read() // { cart: CartState, user: UserState, ui: UiState }
app.subscribe(all => console.log(all.cart.items.length))The combined view attaches to its children lazily and detaches when the last subscriber leaves, so it never keeps idle stores alive.
Microtask batching
Inspired by Valtio. With notify: 'microtask', a burst of synchronous writes
produces exactly one notification:
const store = createStore({ a: 0, b: 0, c: 0 }, { notify: 'microtask' })
store.subscribe(s => s, () => console.log('notified'))
store.patch({ a: 1 })
store.patch({ b: 2 })
store.patch({ c: 3 })
// → logs "notified" once, on the next microtask
store.flush() // or deliver it synchronously right nowLifecycle hooks (lazy stores)
Inspired by Nanostores. Open a resource when the first subscriber arrives and release it when the last one leaves — so an unused store costs nothing:
let socket: WebSocket | null = null
const messages = createStore<{ items: string[] }>(
{ items: [] },
{
// Debounce teardown so a route change or Suspense retry doesn't
// tear down and immediately rebuild the connection.
unmountDelay: 1000,
onSubscribe: (store, listenerCount) => {
if (listenerCount !== 1) return
socket = new WebSocket('wss://example.com/feed')
socket.onmessage = e => {
store.set({ items: [...store.read().items, e.data as string] })
}
},
onUnsubscribe: (_store, listenerCount) => {
if (listenerCount !== 0) return
socket?.close()
socket = null
},
}
)Destroying a store
store.destroy()
// Clears listeners, fires plugin onDestroy hooks, cancels pending
// notifications, and sets version to -1.
store.destroyed // true
store.read() // still works — reads never throw
store.set({ … }) // throws "Store is destroyed"The query layer
Everything TanStack Query is loved for, framework-agnostic and in the same package as your client state.
import { QueryClient } from 'exostate'
const client = new QueryClient()
const observer = client.watch({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 3,
})
observer.subscribe(state => {
// state.data, state.error, state.isLoading, state.isFetching,
// state.isSuccess, state.isError, state.isStale, state.dataUpdatedAt
})
await observer.refetch()
observer.destroy() // release the observer; entry becomes GC-eligiblequeryKey is hashed structurally with sorted object keys, so
['user', { id: 1, tab: 'a' }] and ['user', { tab: 'a', id: 1 }] are the
same cache entry.
Stale-while-revalidate
Cached data is served instantly while a refetch runs in the background, so the UI never blanks out:
const observer = client.watch({ queryKey: ['posts'], queryFn: fetchPosts, staleTime: 60_000 })
// Once resolved and later stale:
observer.getState().data // previous data, still on screen
observer.getState().isFetching // true — refresh in flight
observer.getState().isLoading // false — we have data to showisLoading means "loading with nothing to show". isFetching means "a request
is in flight". Use isLoading for spinners and isFetching for subtle
refresh indicators.
Request deduplication
Concurrent requests for the same key collapse into a single call:
await Promise.all([
client.fetchQuery({ queryKey: ['config'], queryFn }),
client.fetchQuery({ queryKey: ['config'], queryFn }),
client.fetchQuery({ queryKey: ['config'], queryFn }),
])
// queryFn ran exactly onceRetries and backoff
client.watch({
queryKey: ['flaky'],
queryFn,
retry: 3, // or (attempt, error) => boolean
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30_000), // the default
})Invalidation
// Everything under the ['users', …] prefix
await client.invalidateQueries({ queryKey: ['users'] })
// Exactly one entry
await client.invalidateQueries({ queryKey: ['users', 7], exact: true })
// Everything
await client.invalidateQueries()Invalidation marks entries stale and refetches those with active observers. Unobserved entries refetch on next use.
Other cache controls:
client.getQueryData(['users', 7])
client.setQueryData(['users', 7], user => ({ ...user, name: 'Ada' }))
client.getQueryState(['users', 7])
client.cancelQueries({ queryKey: ['users'] })
client.removeQueries({ queryKey: ['users'] })
await client.refetchQueries({ queryKey: ['users'] })
await client.prefetchQuery({ queryKey: ['users', 7], queryFn })Mutations and optimistic updates
onMutate runs before the request and its return value is handed to onError
— which is exactly what you need to roll back:
import { createMutation } from 'exostate'
const addTodo = createMutation<Todo, string, Todo[] | undefined>({
mutationFn: text => api.addTodo(text),
onMutate: text => {
const previous = client.getQueryData<Todo[]>(['todos'])
client.setQueryData<Todo[]>(['todos'], old => [
...(old ?? []),
{ id: 'temp', text },
])
return previous // becomes the rollback context
},
onError: (_error, _text, previous) => {
client.setQueryData(['todos'], previous ?? [])
},
onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
})
await addTodo.mutate('Buy milk')
addTodo.getState() // { data, error, status, isLoading, isSuccess, isError, variables }Garbage collection
When the last observer of a query leaves, its in-flight request is cancelled
and a gcTime countdown starts (default 5 minutes). If nobody observes it
again in that window, the entry is disposed and its memory released. Set
gcTime: Infinity to keep an entry forever.
Server-side rendering
The query core has no window or document access — focus and reconnect
listeners are feature-detected — so it runs unchanged on the server.
// ── Server ──
const client = new QueryClient()
await client.prefetchQuery({ queryKey: ['user', id], queryFn })
const dehydrated = client.dehydrate() // JSON-serializable
res.send(`<script>window.__STATE__ = ${JSON.stringify(dehydrated)}</script>`)
// ── Client ──
const client = new QueryClient()
client.hydrate(window.__STATE__)
// Data is on screen immediately. staleTime is measured from the server's
// fetch time, so no refetch waterfall on first paint.Plain store state has its own SSR pair:
import { dehydrate, rehydrate } from 'exostate'
const json = dehydrate(store) // server
rehydrate(store, json) // clientFramework adapters
React
import { createStore } from 'exostate'
import { useStore, useSelector, useStores } from 'exostate/react'
const counter = createStore({ count: 0, label: 'hits' })
function Counter() {
const count = useSelector(counter, s => s.count)
return <button onClick={() => counter.patch({ count: count + 1 })}>{count}</button>
}
function Whole() {
const state = useStore(counter) // whole store
return <p>{state.label}: {state.count}</p>
}
function Multi() {
const { counter: c, user } = useStores({ counter, user: userStore })
return <p>{user.name} — {c.count}</p>
}Inline object selectors are safe. The selector result is memoized against
the store version, so this does not trip React's
getSnapshot should be cached to avoid an infinite loop error:
const { a, b } = useSelector(store, s => ({ a: s.a, b: s.b }))Add shallow when you also want to skip re-renders for unrelated changes:
import { shallow } from 'exostate'
const slice = useSelector(store, s => ({ a: s.a, b: s.b }), shallow)Query hooks:
import { QueryClient } from 'exostate'
import { QueryClientProvider, useQuery, useMutation } from 'exostate/react/query'
const client = new QueryClient()
function App() {
return (
<QueryClientProvider client={client}>
<Profile id="42" />
</QueryClientProvider>
)
}
function Profile({ id }: { id: string }) {
const { data, isLoading, isError, error, refetch } = useQuery<User>({
queryKey: ['user', id],
queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
staleTime: 30_000,
})
if (isLoading) return <Spinner />
if (isError) return <p>{error?.message}</p>
return <h1 onClick={() => refetch()}>{data?.name}</h1>
}
function AddTodo() {
const { mutate, isLoading } = useMutation<Todo, string>({
mutationFn: text => api.addTodo(text),
onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
})
return <button disabled={isLoading} onClick={() => mutate('New')}>Add</button>
}mutate fires and forgets; mutateAsync returns the promise.
Vue
<script setup lang="ts">
import { createStore } from 'exostate'
import { useExostore, useExoselector } from 'exostate/vue'
const counter = createStore({ count: 0 })
const state = useExostore(counter) // readonly ref to whole state
const count = useExoselector(counter, s => s.count) // readonly ref to a slice
</script>
<template>
<button @click="counter.patch({ count: count + 1 })">{{ count }}</button>
</template>Uses shallowRef (state is already immutable, so deep reactivity would be
wasted work) and onScopeDispose, so it cleans up inside components and
standalone effect scopes.
Svelte
<script lang="ts">
import { createStore } from 'exostate'
import { exostore, exoselector } from 'exostate/svelte'
const counter = createStore({ count: 0 })
const state = exostore(counter)
const count = exoselector(counter, s => s.count)
</script>
<button on:click={() => counter.patch({ count: $count + 1 })}>
{$count}
</button>Implements Svelte's readable-store contract, so $store auto-subscription
works and unsubscription is automatic.
Solid
import { createStore } from 'exostate'
import { useExostore, useExoselector } from 'exostate/solid'
const counter = createStore({ count: 0 })
function Counter() {
const count = useExoselector(counter, s => s.count)
return <button onClick={() => counter.patch({ count: count() + 1 })}>{count()}</button>
}Vanilla JavaScript
No build step, no framework, no adapter:
<script type="module">
import { createStore } from 'https://esm.sh/exostate'
const store = createStore({ count: 0 })
store.subscribe(
s => s.count,
count => { document.getElementById('out').textContent = count }
)
document.getElementById('inc').onclick = () =>
store.patch(prev => ({ count: prev.count + 1 }))
</script>The query layer works the same way — it is plain JavaScript with no framework coupling.
Advanced features
Plugins
Plugins observe and can transform every commit:
import { createStore, logger, freeze } from 'exostate'
const store = createStore({ count: 0 })
store.use(logger({ name: 'MyApp', collapsed: true }))
store.use(freeze()) // deep-freeze state in development to catch mutations
const detach = store.use({
name: 'analytics',
onInit: s => {
const timer = setInterval(() => report(s.read()), 10_000)
return () => clearInterval(timer) // cleanup on detach
},
onBeforeUpdate: (prev, next) => {
// Return a value to replace what gets committed
return { ...next, count: Math.min(next.count, 100) }
},
onAfterUpdate: (prev, next) => track('state_changed', { prev, next }),
onSubscribe: count => console.log('listeners:', count),
onUnsubscribe: count => console.log('listeners:', count),
onDestroy: () => flush(),
})
detach()Middleware
Operation-level instrumentation, including timings:
import { withMiddleware } from 'exostate'
const instrumented = withMiddleware(store, [
{
before: (op, ctx) => console.log('→', op, ctx.version),
after: (op, ctx) => console.log('←', op, `${ctx.durationMs}ms`),
},
])Transactions
Stage several changes and commit or discard them atomically:
import { beginTransaction } from 'exostate'
const tx = beginTransaction(store)
tx.apply(addItem, { id: 'a' })
tx.apply(applyDiscount, 0.2)
tx.read() // staged value — the store is untouched so far
if (isValid(tx.read())) {
tx.commit() // one notification for the whole transaction
} else {
tx.rollback()
}
tx.commit() // throws — a transaction is sealed after commit or rollbackHistory and time travel
import { createHistory } from 'exostate'
const history = createHistory(store, { limit: 50 })
history.attach()
store.patch({ count: 1 })
store.patch({ count: 2 })
history.undo() // back to { count: 1 }
history.redo() // forward to { count: 2 }
history.jumpTo(0) // straight to any recorded entry
history.canUndo() // boolean
history.entries() // recorded snapshots
history.clear()
history.detach()Persistence
import { persistLocal, persistIndexedDB } from 'exostate'
// localStorage / sessionStorage / any StorageLike
const local = persistLocal(store, 'app-state', localStorage)
local.detach()
// IndexedDB — async, no 5 MB cap, survives Date/Map/Set round trips
const idb = await persistIndexedDB(store, {
dbName: 'my-app',
key: 'main',
writeDebounceMs: 50,
})
idb.detach() // flushes anything still queuedFilesystem persistence lives in the Node entry point:
import { persistFs } from 'exostate/node'
const fsPersist = await persistFs(store, './state/app.json')Writes are serialized through a single-slot queue, so a burst of updates collapses to one pending write and can never tear the file.
Event sourcing
An append-only log for audit trails and replay:
import { createEventSource } from 'exostate'
const events = createEventSource(store, { maxEvents: 1000 })
events.dispatch('ITEM_ADDED', { id: 1, name: 'Widget' }, (prev, payload) => ({
...prev,
items: [...prev.items, payload],
}))
events.events() // [{ type, payload, timestamp, version }]
events.eventsSince(5)
events.onEvent(e => audit(e))
events.replay(initialState)Store factories
Isolated stores per widget, modal, or tenant:
import { storeFactory, cachedStoreFactory } from 'exostate'
const createWidget = storeFactory((id: string) => ({ id, items: [] }))
const w1 = createWidget('w1') // independent instances
const w2 = createWidget('w2')
// Same key returns the same instance
const userStores = cachedStoreFactory((userId: string) => ({ id: userId, name: '' }))
userStores.get('u1') === userStores.get('u1') // true
userStores.delete('u1')Redux DevTools
import { connectReduxDevTools } from 'exostate'
const disconnect = connectReduxDevTools(store, { name: 'My App' })
// Time travel from the extension writes back into the store.Schema validation
import { z } from 'zod'
import { fromZod, fromPredicate } from 'exostate'
const schema = fromZod(z.object({ count: z.number() }))
const state = schema.parse(untrustedInput)
const isUser = (x: unknown): x is User => typeof x === 'object' && x !== null && 'id' in x
const userSchema = fromPredicate(isUser)Versioned serialization
Migrate persisted state across schema versions:
import { createSerializer } from 'exostate'
const serializer = createSerializer<StateV3>(3, {
validate: (x): x is StateV3 => typeof x === 'object' && x !== null,
migrations: {
1: (v1: any) => ({ ...v1, theme: 'light' }), // v1 → v2
2: (v2: any) => ({ ...v2, locale: 'en' }), // v2 → v3
},
})
persistLocal(store, 'app', localStorage, {
encode: serializer.encode,
decode: serializer.decode,
})Decoding a payload from a newer version throws rather than silently corrupting state.
Recipes
Error handling with typed errors
import { createError, toSafeError, isSafeError } from 'exostate'
const err = createError('NOT_FOUND', 'User does not exist', { id: 42 })
err.name // 'SafeError'
err.code // 'NOT_FOUND'
err.details // { id: 42 }
const safe = toSafeError(unknownThrowable, 'FETCH_FAILED')Async actions on a plain store (when you want loading flags without the full query cache)
import { asyncAction } from 'exostate'
const load = asyncAction(
userStore,
async (_store, id: string) => ({ user: await api.getUser(id) }),
{
onStart: () => ({ loading: true, error: null }),
onError: err => ({ loading: false, error: err.message }),
retry: 3,
retryDelay: attempt => 2 ** attempt * 100,
latestOnly: true, // default — a slow earlier call can't clobber a newer one
}
)
const promise = load('user-42')
promise.abort() // cancels only this invocationCo-locating actions with state
import { defineStore } from 'exostate'
const counter = defineStore((set, get) => ({
count: 0,
increment: () => set(s => ({ ...s, count: s.count + 1 })),
reset: () => set({ count: 0 }),
}))
counter.read().increment()API reference
Core
| Export | Description |
| --- | --- |
| createStore(initial, options?) | Create a store |
| createState(initial) | Immutable read-only state container |
| defineStore(creator) | Creator pattern with co-located actions |
| storeFactory(init) / cachedStoreFactory(init) | Scoped store instances |
| combineStores(stores) | Compose multiple stores into one view |
| computed(store, selector) | Version-cached derived value |
| derive(store, selector) | Uncached derived value |
| shallow / deepEqual | Comparators for selectors |
Store methods
read · snapshot · version · patch · set · update · compute ·
batch · effect · subscribe · use · plugins · flush · destroy ·
destroyed
Query
| Export | Description |
| --- | --- |
| QueryClient | Cache with SWR, dedup, retries, GC, SSR |
| createMutation(options) | Mutation with optimistic-update support |
| hashQueryKey(key) | Structural key hashing |
QueryClient methods: watch · fetchQuery · prefetchQuery ·
getQueryData · setQueryData · getQueryState · invalidateQueries ·
refetchQueries · cancelQueries · removeQueries · dehydrate ·
hydrate · size · clear
Persistence, history, and integrity
persistLocal · persistIndexedDB · persistFs (from exostate/node) ·
createHistory · beginTransaction · createEventSource ·
createSerializer · dehydrate · rehydrate
Plugins and observability
withMiddleware · logger · freeze · registerPlugin · getPlugins ·
destroyPlugins · devtoolsMiddleware · connectReduxDevTools
Errors and validation
SafeError · createError · isSafeError · toSafeError · applyPolicy ·
fromZod · fromPredicate
FAQ
Does it work without React? Yes. The core has zero framework imports. Vue, Svelte, and Solid have first-class adapters, and plain JavaScript needs no adapter at all.
Does the query layer work on the server?
Yes. There is no window/document access, and dehydrate/hydrate move the
cache across the server-client boundary with fetch timestamps preserved.
Can I use it with Next.js / Nuxt / SvelteKit?
Yes. Create the QueryClient per request on the server, prefetch, dehydrate,
then hydrate on the client.
Is patch a deep merge?
No — shallow, like Zustand. Nested objects are replaced. Shallow is predictable;
deep merging surprises people about arrays.
Do I need useShallow-style wrappers in React?
No. Inline object selectors are memoized against the store version, so they
cannot cause infinite loops. Pass shallow as the comparator when you also want
to skip re-renders for unrelated changes.
Why is version -1 after destroy()?
It is a sentinel marking the store as destroyed. Reads still work; writes throw.
Does it bring dependencies into my bundle? None. Exostate ships zero runtime dependencies; framework packages are optional peers you already have.
Can I migrate gradually from Zustand or TanStack Query? Yes. They are independent — adopt the query layer first, or the store first, and run both side by side during the transition.
Contributing
Contributions are welcome. See CONTRIBUTING.md for setup, project layout, design constraints, and the commit convention.
git clone https://github.com/webcoderspeed/exostate.git
cd exostate
npm install
npm run validatePlease also read the Code of Conduct. Security issues should be reported privately — see SECURITY.md.
