State management and async data fetching in one 1.2 kB package.
Stale-while-revalidate cache · Request deduplication · Optimistic updates · SSR hydration
React · Vue · Svelte · Solid · Vanilla JS · Node.js
GitHub · npm · Issues · Discussions
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.
- 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
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 | 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.
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.
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.
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.)
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()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) => { /* … */ },
}
)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.
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 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))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.
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 nowInspired 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
},
}
)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"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.
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.
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 onceclient.watch({
queryKey: ['flaky'],
queryFn,
retry: 3, // or (attempt, error) => boolean
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30_000), // the default
})// 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 })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 }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.
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) // clientimport { 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.
<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.
<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.
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>
}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.
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()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`),
},
])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 rollbackimport { 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()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.
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)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')import { connectReduxDevTools } from 'exostate'
const disconnect = connectReduxDevTools(store, { name: 'My App' })
// Time travel from the extension writes back into the store.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)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.
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()| 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 |
read · snapshot · version · patch · set · update · compute ·
batch · effect · subscribe · use · plugins · flush · destroy ·
destroyed
| 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
persistLocal · persistIndexedDB · persistFs (from exostate/node) ·
createHistory · beginTransaction · createEventSource ·
createSerializer · dehydrate · rehydrate
withMiddleware · logger · freeze · registerPlugin · getPlugins ·
destroyPlugins · devtoolsMiddleware · connectReduxDevTools
SafeError · createError · isSafeError · toSafeError · applyPolicy ·
fromZod · fromPredicate
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.
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.