state-resource
v0.7.6
Published
Async state resource management
Readme
state-resource
Promise caching, deduplication, invalidation, and shared query state updates with a React hook.
No context, no provider, no boilerplate.
npm i state-resource
# or
bun add state-resourceReact is an optional peer dependency. The core cache (createQuery / invalidate) works in any environment.
Highlights
- Promise-level request deduplication by argument key
- Stale-while-revalidate behavior in
useQuery - Per-entry refresh and global invalidation
- Shared state mutation via
useQuery(...).setData(...) - Fully typed APIs (arguments and data inferred end-to-end)
- Works without React for cache-only use cases
Quick start
// queries.ts
import { createQuery } from 'state-resource'
export const userQuery = createQuery('users', (id: number) =>
fetch(`/api/users/${id}`).then(r => r.json())
)// UserCard.tsx
import { useQuery } from 'state-resource'
import { userQuery } from './queries'
function UserCard({ userId }: { userId: number }) {
const user = useQuery(userQuery, [userId])
if (user.status === 'loading') return <Spinner />
if (user.status === 'error') return <p>Error: {user.error.message}</p>
return (
<div>
<p>{user.data.name}</p>
<button onClick={user.refresh}>Refresh</button>
</div>
)
}API
createQuery(cacheId, fn)
Creates a typed cached query function.
const userQuery = createQuery('users', async (id: number) => {
const res = await fetch(`/api/users/${id}`)
return res.json() as Promise<User>
})| Param | Type | Description |
| --------- | -------------------------------- | ---------------------------------- |
| cacheId | string | Unique key for this query's cache |
| fn | (...args: A) => Promise<R> | Async function that fetches data |
Returns a Query<A, R> — a callable function with extra methods:
| Member | Description |
| --- | --- |
| query(...args) | Returns cached promise for args, or starts a new fetch |
| query.invalidate(...args) | Refetches one cache entry and notifies listeners for that key |
| query.clear() | Clears all cached promises, refetchers, and snapshots for this cacheId |
| query.cacheId | Read-only cache ID |
Caching is argument-based. The same arguments always return the same promise. Object argument key order is normalized, so { a: 1, b: 2 } and { b: 2, a: 1 } hit the same cache entry.
userQuery(1) === userQuery(1) // true — same promise, only one fetch
userQuery(1) !== userQuery(2) // different args → different entriesFailed promises are evicted automatically, so the next call re-fetches cleanly without manual cleanup.
invalidate(target)
Global invalidation: refetches every cached entry currently known for a query.
import { invalidate } from 'state-resource'
// by cacheId string
invalidate('users')
// by query object (same result)
invalidate(userQuery)Use this after a mutation that affects multiple entries at once.
useQuery(query, args)
React hook for subscribing to one query entry.
const result = useQuery(userQuery, [userId])Returns QueryResult<T>:
type QueryResult<T> =
| {
status: 'loading'
data?: T
error?: undefined
refresh: () => void
setData: (next: T | ((prev: T | undefined) => T)) => T
}
| {
status: 'ok'
data: T
error?: undefined
refresh: () => void
setData: (next: T | ((prev: T | undefined) => T)) => T
}
| {
status: 'error'
data?: T
error: Error
refresh: () => void
setData: (next: T | ((prev: T | undefined) => T)) => T
}status: 'loading'— fetch in progress.datacarries the previous value while reloading (stale-while-revalidate).status: 'ok'— fetch succeeded.status: 'error'— fetch failed. Failed promise is evicted from cache so retry is clean.refresh()— refetches this exact cache key. Reference is stable while args are stable.setData(next | updater)— updates shared state for this key and re-renders all subscribers using the same query+args key.
setData behavior
- Accepts either a direct value or updater function
- Updater receives the previous data (
T | undefined) - Writes through to the query cache (future reads for the same key resolve to the updated value)
- Notifies subscribers for the key immediately
function UpvoteButton({ postId }: { postId: number }) {
const post = useQuery(postQuery, [postId])
return (
<button
disabled={post.status !== 'ok'}
onClick={() => post.setData(prev => ({ ...(prev ?? { id: postId, votes: 0 }), votes: (prev?.votes ?? 0) + 1 }))}
>
+1 vote
</button>
)
}Examples
1 · Cache deduplication
Two components call the same query with the same args. Only one HTTP request fires — both share the cached promise.
function Page() {
return (
<>
<UserBadge userId={1} /> {/* fetches userQuery(1) */}
<UserAvatar userId={1} /> {/* cache hit — no second request */}
</>
)
}
function UserBadge({ userId }: { userId: number }) {
const user = useQuery(userQuery, [userId])
if (user.status !== 'ok') return null
return <span>{user.data.name}</span>
}
function UserAvatar({ userId }: { userId: number }) {
const user = useQuery(userQuery, [userId])
if (user.status !== 'ok') return null
return <img src={user.data.avatarUrl} alt={user.data.name} />
}2 · Per-entry invalidation
Refresh a single user without touching any other cached users.
function UserCard({ userId }: { userId: number }) {
const user = useQuery(userQuery, [userId])
return (
<div>
{user.status === 'loading' && <Spinner />}
{user.status === 'ok' && <p>{user.data.name} — {user.data.role}</p>}
{user.status === 'error' && <p>Error: {user.error.message}</p>}
<button onClick={user.refresh} disabled={user.status === 'loading'}>
Refresh
</button>
</div>
)
}user.refresh() calls userQuery.invalidate(userId) internally — only that one cache slot is updated. Other UserCards with different userIds are unaffected.
3 · Global invalidation after mutation
After saving a record, invalidate the whole query so every component refetches.
async function saveUser(user: User) {
await fetch('/api/users', { method: 'POST', body: JSON.stringify(user) })
// Refetch all cached users in every mounted component at once
invalidate(userQuery)
}4 · Shared optimistic updates with setData
All components subscribed to the same key update immediately.
function UserNameEditor({ userId }: { userId: number }) {
const user = useQuery(userQuery, [userId])
const rename = async (name: string) => {
if (user.status !== 'ok') return
// optimistic UI
user.setData({ ...user.data, name })
try {
await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
})
user.refresh() // reconcile with server
} catch {
user.refresh() // rollback by refetching
}
}
return <button onClick={() => rename('New Name')}>Rename</button>
}5 · Multi-argument queries
Arguments are spread, not wrapped — the type system enforces them at the call site.
const postsQuery = createQuery(
'posts',
(userId: number, page: number) =>
fetch(`/api/users/${userId}/posts?page=${page}`).then(r => r.json())
)
// In a component:
const posts = useQuery(postsQuery, [userId, page])6 · Object arguments (stable key)
Object key order is normalized, so re-renders with structurally equal objects don't trigger a new fetch.
const searchQuery = createQuery('search', (filter: { q: string; page: number }) =>
fetch(`/api/search?q=${filter.q}&page=${filter.page}`).then(r => r.json())
)
// These are the same cache entry:
searchQuery({ q: 'hello', page: 1 })
searchQuery({ page: 1, q: 'hello' })function SearchResults() {
const [filter, setFilter] = useState({ q: '', page: 1 })
const results = useQuery(searchQuery, [filter])
return (
<>
<input
value={filter.q}
onChange={e => setFilter(f => ({ ...f, q: e.target.value, page: 1 }))}
/>
{results.status === 'loading' && <Spinner />}
{results.status === 'ok' && results.data.map(r => <p key={r.id}>{r.title}</p>)}
</>
)
}7 · Dependent queries
Load a user, then load their posts only once the userId is known. Switching back to a previously selected user is instant — both caches are warm.
function UserDashboard() {
const [userId, setUserId] = useState(1)
const user = useQuery(userQuery, [userId])
return (
<div>
<UserSelector onChange={setUserId} />
{user.status === 'ok' && (
<>
<UserProfile user={user.data} />
<PostList userId={userId} />
</>
)}
</div>
)
}
function PostList({ userId }: { userId: number }) {
const posts = useQuery(postsQuery, [userId])
if (posts.status === 'loading') return <Spinner />
if (posts.status === 'error') return <p>Failed to load posts</p>
return (
<ul>
{posts.data.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
)
}8 · Error recovery
Failed fetches are evicted from the cache automatically. Calling refresh() or changing args re-fetches cleanly.
function WeatherCard({ city }: { city: string }) {
const weather = useQuery(weatherQuery, [city])
return (
<div>
{weather.status === 'loading' && <Spinner />}
{weather.status === 'ok' && <p>{weather.data.temp}°C — {weather.data.condition}</p>}
{weather.status === 'error' && (
<>
<p>Error: {weather.error.message}</p>
<button onClick={weather.refresh}>Retry</button>
</>
)}
</div>
)
}9 · Using the cache outside React
createQuery and invalidate are plain functions — no React required.
import { createQuery, invalidate } from 'state-resource'
const configQuery = createQuery('config', () =>
fetch('/api/config').then(r => r.json())
)
// Warm the cache at app startup
await configQuery()
// Later, after config changes on the server:
invalidate(configQuery)10 · Clearing the cache
Use query.clear() to wipe all entries — useful on logout or when switching accounts.
async function logout() {
await fetch('/api/logout', { method: 'POST' })
// Purge all cached data
userQuery.clear()
postsQuery.clear()
settingsQuery.clear()
}11 · Manual filter on already-fetched data
You can derive local view state from remote data without extra context stores.
function FilteredUsers() {
const [search, setSearch] = useState('')
const users = useQuery(usersQuery, [{ search: '' }])
const applyLocalFilter = () => {
const needle = search.toLowerCase().trim()
users.setData(prev => (prev ?? []).filter(user => user.name.toLowerCase().includes(needle)))
}
return (
<div>
<input value={search} onChange={e => setSearch(e.target.value)} />
<button onClick={applyLocalFilter}>Filter Local Data</button>
<button onClick={users.refresh}>Refetch</button>
</div>
)
}TypeScript
Fully typed. Arguments and return types flow through automatically.
const userQuery = createQuery('users', async (id: number) => {
const res = await fetch(`/api/users/${id}`)
return res.json() as Promise<{ id: number; name: string }>
})
// TypeScript knows: data is { id: number; name: string }
const user = useQuery(userQuery, [1])
if (user.status === 'ok') {
console.log(user.data.name) // ✓
}
// TypeScript enforces args — this won't compile:
useQuery(userQuery, ['not-a-number']) // ✗ Argument of type 'string' is not assignable to type 'number'Exports
import {
createQuery,
invalidate,
useQuery,
type Query,
type QueryState,
type Snapshot,
} from 'state-resource'How it works
- Promise cache:
Map<cacheId, Map<argsKey, Promise<R>>> - Snapshots:
Map<cacheId, Map<argsKey, Snapshot<R>>>tracksloading | ok | errorplus optional stale data - Listeners: subscribers are stored per cacheId + argsKey
- Stable keys: generated via
stableKey(args)with deterministic object-key ordering - Fetch flow:
- set
loadingsnapshot - run fetch
- set
okorerrorsnapshot - notify listeners for that key
- set
- Error eviction: on rejected fetch, promise cache entry is removed for clean retries
- React integration:
useSyncExternalStoresubscribes components to key-level snapshot updates - Shared mutation:
setDatawrites snapshot + cache and broadcasts key updates immediately
Best practices
- Use descriptive
cacheIdvalues (users,posts,weather) and keep them stable. - Keep args serializable and deterministic.
- Use
refresh()for entry-level revalidation. - Use
invalidate(queryOrCacheId)after broad mutations. - Use
setDatafor optimistic updates and local derivations, thenrefresh()to reconcile with server data when needed. - Call
query.clear()on logout/account switch to avoid cross-user stale data.
License
MIT
