Skip to content

Repository files navigation

exostate

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

npm versionnpm downloadsbundle sizeCIMITTypeScriptzero dependencies

GitHub · npm · Issues · Discussions


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 panel

Exostate is one package, one mental model, every framework:

npm install exostate
import{createStore,QueryClient}from'exostate'// Client state — synchronous, immutable, type-safeconstui=createStore({theme: 'dark',sidebarOpen: false})ui.patch({sidebarOpen: true})// Server state — cached, deduplicated, revalidatedconstclient=newQueryClient()constuser=awaitclient.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

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

FeatureExostateZustandJotaiNanostoresRedux ToolkitTanStack Query
Client state
Async query cachepartialRTK Query
Stale-while-revalidate
Request deduplication
Optimistic updates + rollbackmanualmanualmanual
Cache garbage collection
SSR dehydrate / hydratemanualmanualmanual
React adapter
Vue adapter
Svelte adapter
Solid adapter
Vanilla JS
Transactions with rollback
Undo / redo history
Event sourcing / audit log
Plugin systemmiddlewaremiddleware
Lazy mount/unmount lifecycle
Microtask batchingn/a
IndexedDB persistencepluginpluginpluginplugin
Redux DevTools
Runtime dependencies00003+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 importGzipped
createStore only1.16 kB
createStore + computed + persistLocal + createHistory1.92 kB
Query layer (QueryClient + createMutation)3.84 kB
React adapter (all hooks)913 B
React query hooks2.37 kB
Entire library, nothing tree-shaken7.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

LibraryOperationOps/sec
Exostatepatch / assign19,833,891
ZustandsetState14,755,883
Exostateupdate(reducer)7,648,695
Reduxdispatch5,273,404

Updates with one subscriber

LibraryOperationOps/sec
Exostatepatch / assign16,114,630
ZustandsetState14,683,389
Exostateupdate(reducer)6,941,873
Reduxdispatch4,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 exostate

Framework packages are optional peer dependencies — install only what you use.

Entry pointImport fromRequires
Core (framework-agnostic)exostate
React hooksexostate/reactreact >= 18
React query hooksexostate/react/queryreact >= 18
Vue composablesexostate/vuevue >= 3
Svelte storesexostate/sveltesvelte >= 4
Solid signalsexostate/solidsolid-js >= 1
Filesystem persistenceexostate/nodeNode >= 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'interfaceCartState{items: Array<{id: string;qty: number}>coupon: string|null}constcart=createStore<CartState>({items: [],coupon: null})// Readcart.read()// CartStatecart.snapshot()// DeepReadonly<CartState>// Write — shallow merge, Zustand-stylecart.patch({coupon: 'SUMMER25'})// Write — functionalcart.patch(prev=>({items: [...prev.items,{id: 'sku-1',qty: 1}]}))// Write — with a reducer, for logic you want named and testableconstaddItem=(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 changesconstunsubscribe=cart.subscribe(s=>s.items.length,count=>console.log('item count:',count))unsubscribe()

Core concepts

Creating a store

import{createStore}from'exostate'conststore=createStore({count: 0})

With options:

conststore=createStore({count: 0},{notify: 'microtask',// coalesce notifications — see belowunmountDelay: 1000,// grace period before onUnsubscribe firesplugins: [logger()],// attach plugins at constructiononSubscribe: (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.

MethodUse 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 groupstore.batch(apply=>{apply((prev,by: number)=>({count: prev.count+by}),1)apply((prev,by: number)=>({count: prev.count*by}),3)})

patch performs a shallow merge. Nested objects are replaced, not merged — the same rule Zustand uses, chosen because it is predictable.

Subscribing and selectors

constunsubscribe=store.subscribe(s=>s.user.name,// selector — subscription is scoped to thisname=>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'constfullName=computed(userStore,s=>`${s.firstName}${s.lastName}`)fullName.read()// computesfullName.read()// cached — no recomputationfullName.subscribe(name=>console.log(name))

Combining stores

import{combineStores}from'exostate'constapp=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:

conststore=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 microtaskstore.flush()// or deliver it synchronously right now

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

letsocket: WebSocket|null=nullconstmessages=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)returnsocket=newWebSocket('wss://example.com/feed')socket.onmessage=e=>{store.set({items: [...store.read().items,e.dataasstring]})}},onUnsubscribe: (_store,listenerCount)=>{if(listenerCount!==0)returnsocket?.close()socket=null},})

Destroying a store

store.destroy()// Clears listeners, fires plugin onDestroy hooks, cancels pending// notifications, and sets version to -1.store.destroyed// truestore.read()// still works — reads never throwstore.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'constclient=newQueryClient()constobserver=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})awaitobserver.refetch()observer.destroy()// release the observer; entry becomes GC-eligible

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

constobserver=client.watch({queryKey: ['posts'],queryFn: fetchPosts,staleTime: 60_000})// Once resolved and later stale:observer.getState().data// previous data, still on screenobserver.getState().isFetching// true — refresh in flightobserver.getState().isLoading// false — we have data to show

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

awaitPromise.all([client.fetchQuery({queryKey: ['config'], queryFn }),client.fetchQuery({queryKey: ['config'], queryFn }),client.fetchQuery({queryKey: ['config'], queryFn }),])// queryFn ran exactly once

Retries and backoff

client.watch({queryKey: ['flaky'],
queryFn,retry: 3,// or (attempt, error) => booleanretryDelay: attempt=>Math.min(1000*2**attempt,30_000),// the default})

Invalidation

// Everything under the ['users', …] prefixawaitclient.invalidateQueries({queryKey: ['users']})// Exactly one entryawaitclient.invalidateQueries({queryKey: ['users',7],exact: true})// Everythingawaitclient.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']})awaitclient.refetchQueries({queryKey: ['users']})awaitclient.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'constaddTodo=createMutation<Todo,string,Todo[]|undefined>({mutationFn: text=>api.addTodo(text),onMutate: text=>{constprevious=client.getQueryData<Todo[]>(['todos'])client.setQueryData<Todo[]>(['todos'],old=>[
...(old??[]),{id: 'temp', text },])returnprevious// becomes the rollback context},onError: (_error,_text,previous)=>{client.setQueryData(['todos'],previous??[])},onSettled: ()=>client.invalidateQueries({queryKey: ['todos']}),})awaitaddTodo.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 ──constclient=newQueryClient()awaitclient.prefetchQuery({queryKey: ['user',id], queryFn })constdehydrated=client.dehydrate()// JSON-serializableres.send(`<script>window.__STATE__ = ${JSON.stringify(dehydrated)}</script>`)// ── Client ──constclient=newQueryClient()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'constjson=dehydrate(store)// serverrehydrate(store,json)// client

Framework adapters

React

import{createStore}from'exostate'import{useStore,useSelector,useStores}from'exostate/react'constcounter=createStore({count: 0,label: 'hits'})functionCounter(){constcount=useSelector(counter,s=>s.count)return<buttononClick={()=>counter.patch({count: count+1})}>{count}</button>}functionWhole(){conststate=useStore(counter)// whole storereturn<p>{state.label}: {state.count}</p>}functionMulti(){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'constslice=useSelector(store,s=>({a: s.a,b: s.b}),shallow)

Query hooks:

import{QueryClient}from'exostate'import{QueryClientProvider,useQuery,useMutation}from'exostate/react/query'constclient=newQueryClient()functionApp(){return(<QueryClientProviderclient={client}><Profileid="42"/></QueryClientProvider>)}functionProfile({ 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<h1onClick={()=>refetch()}>{data?.name}</h1>}functionAddTodo(){const{ mutate, isLoading }=useMutation<Todo,string>({mutationFn: text=>api.addTodo(text),onSettled: ()=>client.invalidateQueries({queryKey: ['todos']}),})return<buttondisabled={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 stateconst 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

<scriptlang="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>
<buttonon: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'constcounter=createStore({count: 0})functionCounter(){constcount=useExoselector(counter,s=>s.count)return<buttononClick={()=>counter.patch({count: count()+1})}>{count()}</button>}

Vanilla JavaScript

No build step, no framework, no adapter:

<scripttype="module">import{createStore}from'https://esm.sh/exostate'conststore=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'conststore=createStore({count: 0})store.use(logger({name: 'MyApp',collapsed: true}))store.use(freeze())// deep-freeze state in development to catch mutationsconstdetach=store.use({name: 'analytics',onInit: s=>{consttimer=setInterval(()=>report(s.read()),10_000)return()=>clearInterval(timer)// cleanup on detach},onBeforeUpdate: (prev,next)=>{// Return a value to replace what gets committedreturn{ ...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'constinstrumented=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'consttx=beginTransaction(store)tx.apply(addItem,{id: 'a'})tx.apply(applyDiscount,0.2)tx.read()// staged value — the store is untouched so farif(isValid(tx.read())){tx.commit()// one notification for the whole transaction}else{tx.rollback()}tx.commit()// throws — a transaction is sealed after commit or rollback

History and time travel

import{createHistory}from'exostate'consthistory=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 entryhistory.canUndo()// booleanhistory.entries()// recorded snapshotshistory.clear()history.detach()

Persistence

import{persistLocal,persistIndexedDB}from'exostate'// localStorage / sessionStorage / any StorageLikeconstlocal=persistLocal(store,'app-state',localStorage)local.detach()// IndexedDB — async, no 5 MB cap, survives Date/Map/Set round tripsconstidb=awaitpersistIndexedDB(store,{dbName: 'my-app',key: 'main',writeDebounceMs: 50,})idb.detach()// flushes anything still queued

Filesystem persistence lives in the Node entry point:

import{persistFs}from'exostate/node'constfsPersist=awaitpersistFs(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'constevents=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'constcreateWidget=storeFactory((id: string)=>({ id,items: []}))constw1=createWidget('w1')// independent instancesconstw2=createWidget('w2')// Same key returns the same instanceconstuserStores=cachedStoreFactory((userId: string)=>({id: userId,name: ''}))userStores.get('u1')===userStores.get('u1')// trueuserStores.delete('u1')

Redux DevTools

import{connectReduxDevTools}from'exostate'constdisconnect=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'constschema=fromZod(z.object({count: z.number()}))conststate=schema.parse(untrustedInput)constisUser=(x: unknown): x is User=>typeofx==='object'&&x!==null&&'id'inxconstuserSchema=fromPredicate(isUser)

Versioned serialization

Migrate persisted state across schema versions:

import{createSerializer}from'exostate'constserializer=createSerializer<StateV3>(3,{validate: (x): x is StateV3=>typeofx==='object'&&x!==null,migrations: {1: (v1: any)=>({ ...v1,theme: 'light'}),// v1 → v22: (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'consterr=createError('NOT_FOUND','User does not exist',{id: 42})err.name// 'SafeError'err.code// 'NOT_FOUND'err.details// { id: 42 }constsafe=toSafeError(unknownThrowable,'FETCH_FAILED')

Async actions on a plain store (when you want loading flags without the full query cache)

import{asyncAction}from'exostate'constload=asyncAction(userStore,async(_store,id: string)=>({user: awaitapi.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})constpromise=load('user-42')promise.abort()// cancels only this invocation

Co-locating actions with state

import{defineStore}from'exostate'constcounter=defineStore((set,get)=>({count: 0,increment: ()=>set(s=>({ ...s,count: s.count+1})),reset: ()=>set({count: 0}),}))counter.read().increment()

API reference

Core

ExportDescription
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 / deepEqualComparators for selectors

Store methods

read · snapshot · version · patch · set · update · compute · batch · effect · subscribe · use · plugins · flush · destroy · destroyed

Query

ExportDescription
QueryClientCache 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 validate

Please also read the Code of Conduct. Security issues should be reported privately — see SECURITY.md.


License

MIT © Sanjeev Sharma

About

State management + async query cache in one 1.2 kB package. Stale-while-revalidate, request deduplication, optimistic updates, SSR hydration. React, Vue, Svelte, Solid & vanilla JS. Zero dependencies.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages