| name | lambda-query | ||||
|---|---|---|---|---|---|
| description | Skill for using @studiolambda/query, a lightweight isomorphic SWR-style async data management library. Use when writing, editing, reviewing, or testing code that uses createQuery, useQuery, QueryProvider, cache mutations, hydration, event subscriptions, or any @studiolambda/query API. Covers the core framework-agnostic library and React 19+ bindings (hooks and components). | ||||
| metadata |
|
Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data management library with React 19+ bindings.
Install: npm i @studiolambda/query
Import paths:
- Core:
@studiolambda/query - React:
@studiolambda/query/react
import { createQuery } from '@studiolambda/query'
const query = createQuery({
fetcher: async (key, { signal }) => {
const res = await fetch(key, { signal })
if (!res.ok) throw new Error(res.statusText)
return res.json()
},
expiration: () => 5000, // cache for 5 seconds
stale: true, // return stale data while revalidating (default)
removeOnError: false, // keep cached item on fetch error (default)
fresh: false, // respect cache (default)
})| Option | Type | Default | Description |
|---|---|---|---|
expiration |
(item: T) => number |
() => 2000 |
Cache duration in ms |
fetcher |
(key: string, { signal }) => Promise<T> |
fetch-based JSON |
Data fetcher function |
stale |
boolean |
true |
Return stale data while revalidating |
removeOnError |
boolean |
false |
Remove cached item on fetch error |
fresh |
boolean |
false |
Always bypass cache |
Instance-only options: itemsCache, resolversCache, events (EventTarget), broadcast (BroadcastChannel).
const data = await query.query<User>('/api/user/1')
// Per-query option overrides
const data = await query.query<User>('/api/user/1', {
fetcher: customFetcher,
stale: false,
fresh: true,
})// Direct value
await query.mutate('/api/user', updatedUser)
// Function based on previous value
await query.mutate<Post[]>('/api/posts', (previous) => [...(previous ?? []), newPost])
// Async mutation
await query.mutate('/api/posts', async (previous) => {
const post = await createNewPost()
return [...(previous ?? []), post]
})
// With custom expiration
await query.mutate('/api/user', updatedUser, { expiration: () => 10000 })await query.forget('/api/user') // Single key
await query.forget(['/api/user', '/api/posts']) // Multiple keys
await query.forget(/^\/api\/users(.*)/) // Regex pattern
await query.forget() // All keysNote: forget only removes items from the items cache -- it does not cancel pending resolvers. Use abort to cancel in-flight requests. If a cached promise has rejected, forget handles it gracefully (emits 'forgotten' with undefined).
query.hydrate('/api/user', serverData, { expiration: () => 10000 })
query.hydrate(['/api/post/1', '/api/post/2'], defaultPost)query.abort('/api/user') // Abort single key
query.abort(['/api/user', '/api/posts']) // Abort multiple
query.abort() // Abort all pending
query.abort('/api/user', 'cancelled') // With custom reasonNote: When fresh: true is used, abort(key) is called before refetch(key) to ensure a genuinely new fetch starts instead of returning the pending deduplication promise.
const value = await query.snapshot<User>('/api/user') // Current cached value or undefined
const itemKeys = query.keys('items') // readonly string[]
const resolverKeys = query.keys('resolvers') // readonly string[]
const date = query.expiration('/api/user') // Expiration Date or undefinedquery.configure({ expiration: () => 10000, stale: false })Events: refetching, resolved, mutating, mutated, aborted, forgotten, hydrated, error.
// Subscribe (returns unsubscriber)
const unsub = query.subscribe('/api/user', 'resolved', (event) => {
console.log('resolved:', event.detail)
})
unsub()
// One-time listener (supports optional AbortSignal for cleanup)
const event = await query.once('/api/user', 'resolved')
const event = await query.once('/api/user', 'resolved', signal) // cancellable
// Await next fetch resolution (supports optional AbortSignal)
const result = await query.next<string>('/api/user')
const [a, b] = await query.next<[User, Config]>(['/api/user', '/api/config'])
const obj = await query.next<{ user: User }>({ user: '/api/user' })
// Stream resolutions (async generator -- cleans up listeners on break/return)
for await (const value of query.stream<User>('/api/user')) {
console.log(value)
}
// Stream arbitrary events (async generator -- cleans up listeners on break/return)
for await (const event of query.sequence<User>('/api/user', 'resolved')) {
console.log(event.detail)
}// Must configure broadcast manually in vanilla usage
query.configure({ broadcast: new BroadcastChannel('query') })
const unsub = query.subscribeBroadcast()
// ... later
unsub()Note: subscribeBroadcast() captures the broadcast reference at call time. If configure() later replaces the channel, the unsubscriber still targets the original. emit() wraps postMessage in try-catch for non-cloneable data.
Designed for React 19+ with first-class Suspense and Transitions support. Uses React Compiler for automatic memoization -- do NOT use useMemo, useCallback, or React.memo.
import { QueryProvider } from '@studiolambda/query/react'
import { createQuery } from '@studiolambda/query'
const query = createQuery({ fetcher: myFetcher })
function App() {
return (
<QueryProvider query={query} clearOnForget>
<Suspense fallback={<Loading />}>
<MyComponents />
</Suspense>
</QueryProvider>
)
}QueryProvider props:
query?- Query instance (creates one if omitted)clearOnForget?- Auto-refetch afterforget()(defaultfalse)ignoreTransitionContext?- Use local transitions instead of shared (defaultfalse)
QueryProvider automatically handles BroadcastChannel setup, cleanup, and cross-tab event forwarding. Includes a guard for environments where BroadcastChannel is unavailable.
The primary hook. Components using it must be inside <Suspense>.
import { useQuery } from '@studiolambda/query/react'
function UserProfile() {
const { data, isPending, isRefetching, refetch, mutate, forget } = useQuery<User>('/api/user/1')
return (
<div style={{ opacity: isPending ? 0.5 : 1 }}>
<h1>{data.name}</h1>
<button onClick={() => refetch()}>Refresh</button>
<button onClick={() => mutate({ ...data, name: 'New Name' })}>Update</button>
<button onClick={() => forget()}>Clear</button>
</div>
)
}Returns:
| Property | Type | Description |
|---|---|---|
data |
T |
Resolved data (always available, Suspense handles loading) |
isPending |
boolean |
Transition pending for mutations/refetches |
expiresAt |
Date |
When cached data expires |
isExpired |
boolean |
Whether data is stale |
isRefetching |
boolean |
Background refetch in progress |
isMutating |
boolean |
Mutation in progress (async mutations only) |
refetch |
(options?) => Promise<T> |
Trigger fresh fetch |
mutate |
(value, options?) => Promise<T> |
Optimistic mutation |
forget |
() => Promise<void> |
Clear cached data |
Options (second argument): All core Options fields plus query?, clearOnForget?, ignoreTransitionContext?.
Actions without data subscription. Use when you need to mutate/refetch from a sibling component.
const { refetch, mutate, forget } = useQueryActions<User>('/api/user/1')Status without data subscription.
const { expiresAt, isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1')Minimal hook returning only data and isPending. Correctly resets data when the key changes to a different cached value.
const { data, isPending } = useQueryBasic<User>('/api/user/1')Get the raw Query instance from context. Throws if none found.
const queryInstance = useQueryInstance()Prefetch keys on mount.
import { useMemo } from 'react'
const keys = useMemo(() => ['/api/user/1', '/api/config'], [])
useQueryPrefetch(keys)Share a single transition across multiple useQuery calls:
import { QueryTransition } from '@studiolambda/query/react'
import { useTransition } from 'react'
function App() {
const [isPending, startTransition] = useTransition()
return (
<QueryTransition isPending={isPending} startTransition={startTransition}>
<UserList />
<UserDetails />
</QueryTransition>
)
}import { useMemo } from 'react'
const keys = useMemo(() => ['/api/user', '/api/config'], [])
<QueryPrefetch keys={keys}>
<Content />
</QueryPrefetch>
// Also renders <link rel="preload" as="fetch"> tags
<QueryPrefetchTags keys={keys}>
<Content />
</QueryPrefetchTags>import { createQuery } from '@studiolambda/query'
import { useQuery } from '@studiolambda/query/react'
import { act, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
it('renders user data', async ({ expect }) => {
const query = createQuery({ fetcher: () => Promise.resolve({ name: 'Ada' }) })
const promise = query.next<User>('/api/user')
function Component() {
const { data } = useQuery<User>('/api/user', { query })
return <span>{data.name}</span>
}
const el = document.createElement('div')
await act(async () => {
createRoot(el).render(
<Suspense fallback="loading"><Component /></Suspense>
)
})
await act(async () => { await promise })
expect(el.innerText).toBe('Ada')
})Pattern: create query with mock fetcher, pass it via { query } option to bypass context, use query.next(key) to await resolution.
- Expiration is a function, not a number. Always
expiration: () => 5000, notexpiration: 5000. useQuerysuspends. Components must be inside<Suspense>or React throws.datafromuseQueryis always resolved. Never undefined/null from loading state. Suspense handles loading.hydratewithout expiration creates immediately-stale data. The firstquery()returns the hydrated value, the second triggers a refetch.- Mutation with
expiration: () => 0makes the value immediately stale. Provide a non-zero expiration if you want it to persist. forgetdoes not cancel pending fetches. Only removes items from the items cache. Useabortto cancel in-flight requests.stale: falseblocks until refetch completes. Defaultstale: truereturns old data while revalidating in the background.subscribe('refetching')on a key with a pending resolver fires immediately. Intentional for late subscribers.- BroadcastChannel is not auto-created in vanilla usage.
QueryProviderhandles it in React. In core, configure it manually. - Pass stable
keysarrays touseQueryPrefetch/QueryPrefetch. UseuseMemoor a module-level constant to avoid infinite re-renders. useQueryInstancethrows if no query is in context or options. EnsureQueryProvideris an ancestor or pass{ query }in options.- React Compiler handles memoization. Do NOT use
useMemo,useCallback, orReact.memo-- the compiler does it automatically. once()andnext()accept an optionalAbortSignal. Use to cancel pending listeners when breaking out of generators.stream()andsequence()clean up on break. InternalAbortControllercancels pending listeners viafinallyblock.- Abort race condition is handled. If
abort()fires after fetch resolves but before cache write, the result is discarded and the promise rejects. next()supports object keys.await query.next<{ user: User }>({ user: '/api/user' })returns an object with the same shape.fresh: trueaborts then refetches. Ensures a genuinely new fetch instead of returning the pending deduplication promise.