@pyreon/query
v0.45.0
Published
Pyreon adapter for TanStack Query
Readme
@pyreon/query
Pyreon adapter for TanStack Query — reactive queries, mutations, suspense, subscriptions, SSE.
@pyreon/query wraps @tanstack/query-core with Pyreon's fine-grained signal model: every observer field (data, error, isFetching, …) is its own Signal<T>, so reading query.data() doesn't re-run when isFetching flips. Query options are passed as a function so reactive values (signal-driven query keys, params) trigger automatic refetches; mutation options are a plain object because mutations are imperative. Ships useQuery / useMutation / useInfiniteQuery / useQueries, Suspense variants, useSubscription (WebSocket) + useSSE (Server-Sent Events) with QueryClient cache integration, and the full @tanstack/query-core re-export so consumers get one import surface.
Install
bun add @pyreon/query @pyreon/core @pyreon/reactivity
# @tanstack/query-core is a hard dependency, installed automaticallyQuick start
import { QueryClient, QueryClientProvider, useQuery } from '@pyreon/query'
const queryClient = new QueryClient()
function UserProfile(props: { id: string }) {
const query = useQuery(() => ({
queryKey: ['user', props.id],
queryFn: () => fetch(`/api/users/${props.id}`).then((r) => r.json()),
}))
return () => {
if (query.isLoading()) return <p>Loading...</p>
if (query.isError()) return <p>Error: {query.error()?.message}</p>
return <h1>{query.data()?.name}</h1>
}
}
const App = () => (
<QueryClientProvider client={queryClient}>
<UserProfile id="1" />
</QueryClientProvider>
)Performance vs @tanstack/react-query
Both adapters wrap the same @tanstack/query-core (pinned to one version tree-wide), so a head-to-head measures the adapter — not the query engine. On a data-only change (setQueryData, so status / isFetching / error don't move):
| Scenario | @pyreon/query | @tanstack/react-query |
| --- | --- | --- |
| Cross-component (one reads status, one data) → flip data | data re-runs, status skips (1) | 🤝 tie — tracked-props skips status, re-renders data (1) |
| Intra-component (one reads all 8 fields) → flip data | 0 component re-runs · 1 field-derivation | 1 whole-component re-render · 8 field-derivations + VDOM reconcile |
| Data-flip → DOM | ~1.6 µs (synchronous fine-grained patch) | ~6.3 µs (macrotask-batched render) — ~4× |
| Mount a 1-query component | ~11 µs | ~12 µs — 🤝 ~tied |
Honest read: react-query is not naive — its tracked-props make it field-aware across components (the first row is a real tie). Pyreon's structural win is within a component — a signal-granular update re-runs only the binding for the changed field, where react-query re-runs the whole component body. Mounting is comparable. Reproduce: bun run --filter=@pyreon/query bench:react-query (NODE_ENV=production, real react-dom@19, deterministic counts + process-isolated timing). Author-judged; the counts are exact and the ns ratios are the portable signal.
Feature parity with the TanStack adapter family
@pyreon/query is feature-complete against @tanstack/{react,solid,svelte}-query and adds Pyreon-native extras:
| Capability | Status |
| --- | --- |
| Reactive query key (options-as-function) | ✅ — signal read in options() refetches automatically (no dependency array) |
| Fine-grained per-field signals | ✅ — the differentiator (see the benchmark above) |
| useQuery / useMutation / useInfiniteQuery / useQueries | ✅ |
| Suspense (useSuspenseQuery / …InfiniteQuery / …Queries + <QuerySuspense>) | ✅ |
| select, placeholderData, keepPreviousData, enabled, staleTime, retry, … | ✅ — all QueryObserverOptions pass straight through to query-core |
| Optimistic updates (onMutate + setQueryData) | ✅ — inherited from query-core |
| SSR dehydrate / hydrate (dehydrate / hydrate / <HydrationBoundary>) | ✅ |
| Persistence (<PersistQueryClientProvider> + useIsRestoring, /persist subpath) | ✅ |
| Devtools (/devtools subpath) | ✅ |
| defineQueries (named parallel queries → typed object) | ✅ Pyreon extra |
| useSubscription (WebSocket) + useSSE (Server-Sent Events) | ✅ Pyreon extra |
Deliberate scope: useSSE's Last-Event-ID cannot be set on the FIRST EventSource connection (a platform limitation) — pair with useStorage + the initialLastEventId option to resume across remounts (see the SSE section).
useQuery(() => options)
Subscribe to a query with fine-grained signals. Options are a function — read signals inside and the observer reconfigures + refetches when they change.
const userId = signal(1)
const query = useQuery(() => ({
queryKey: ['user', userId()],
queryFn: () => fetchUser(userId()),
}))
// userId.set(2) → automatic refetchReturns UseQueryResult<TData, TError>:
| Property | Type | Notes |
| ------------- | ----------------------------------------------- | -------------------------------------- |
| result | Signal<QueryObserverResult> | Full observer result (escape hatch) |
| data | Signal<TData \| undefined> | |
| error | Signal<TError \| null> | |
| status | Signal<'pending' \| 'error' \| 'success'> | |
| isPending | Signal<boolean> | No data yet |
| isLoading | Signal<boolean> | First fetch in progress |
| isFetching | Signal<boolean> | Any fetch (incl. background refetch) |
| isError | Signal<boolean> | |
| isSuccess | Signal<boolean> | |
| refetch() | () => Promise<QueryObserverResult> | Manual refetch |
Lazy signal allocation: each property is materialized on first access via getter (??=), so a consumer that only reads data and isLoading doesn't allocate the other 7 signals. Same Signal<T> identity is preserved across repeat access.
useMutation(options)
Mutations are imperative — options are a plain object, not a function.
const mutation = useMutation({
mutationFn: (post: { title: string }) =>
fetch('/api/posts', { method: 'POST', body: JSON.stringify(post) }).then((r) => r.json()),
// Auto-invalidate queries on success (extension of TanStack's interface)
invalidates: [['posts']],
onSuccess: (data) => console.log('Created', data),
})
mutation.mutate({ title: 'Hello' }) // fire-and-forget, errors land in mutation.error()
await mutation.mutateAsync({ title: '!' }) // promise — use for try/catch
mutation.reset() // back to idleUseMutationResult shape mirrors UseQueryResult plus mutate / mutateAsync / reset, with status 'idle' | 'pending' | 'success' | 'error'.
useInfiniteQuery(() => options) / useSuspenseQuery / useSuspenseInfiniteQuery
Same shape as useQuery. Suspense variants narrow data to Signal<TData> (non-undefined after suspense resolves) and MUST be wrapped in <QuerySuspense>.
function UserList() {
const query = useSuspenseQuery(() => ({
queryKey: ['users'],
queryFn: fetchUsers,
}))
return () => (
<ul>
{query.data().map((u) => (
<li>{u.name}</li>
))}
</ul>
)
}
;<QuerySuspense fallback={<p>Loading...</p>}>
<UserList />
</QuerySuspense>useQueries(() => options)
Multiple parallel queries. Options as a function so the query array itself can be reactive.
const ids = signal([1, 2, 3])
const queries = useQueries(() =>
ids().map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id) })),
)
// queries is an array of UseQueryResultdefineQueries({ a, b, c })
Named parallel queries returning a typed object instead of an array.
const queries = defineQueries({
user: () => ({ queryKey: ['user'], queryFn: fetchUser }),
posts: () => ({ queryKey: ['posts'], queryFn: fetchPosts }),
})
queries.user.data()
queries.posts.data()useSubscription(options)
Reactive WebSocket with auto-reconnect. onMessage receives the active QueryClient so pushes can directly invalidate cache. Exponential backoff (default 1s doubling, max 10 attempts). url and enabled may be signals.
const sub = useSubscription({
url: 'wss://api.example.com/feed',
onMessage: (event, client) => {
const msg = JSON.parse(event.data)
if (msg.type === 'post-created') {
client.invalidateQueries({ queryKey: ['posts'] })
}
},
})
// sub.status() | sub.send(data) | sub.close() | sub.reconnect()useSSE(options)
Server-Sent Events — same shape as useSubscription, read-only. parse deserializes each event; events filters named event types. lastEventId() updates on every incoming id field.
const sse = useSSE({
url: '/api/events',
parse: JSON.parse,
onMessage: (data, queryClient) => {
if (data.type === 'order-updated') {
queryClient.invalidateQueries({ queryKey: ['orders'] })
}
},
})Resuming across remount: EventSource cannot set Last-Event-ID on the FIRST connection — pair useStorage with the initialLastEventId option and pass the ID in the URL so the server reads it as a query param:
const lastId = useStorage('chat-last-id', '')
const sse = useSSE({
url: () => `/api/events?lastId=${lastId() || ''}`,
initialLastEventId: lastId,
onMessage: (msg) => lastId.set(msg.id),
})initialLastEventId is read once at mount — subsequent changes are ignored. Use the reactive url (or sse.reconnect()) for runtime overrides.
useIsFetching(filters?) / useIsMutating(filters?)
Global counters as reactive signals — useful for top-of-page spinners.
const fetching = useIsFetching() // Signal<number>
const mutating = useIsMutating({ mutationKey: ['posts'] })QueryErrorResetBoundary / useQueryErrorResetBoundary()
Pair with a sibling ErrorBoundary so the fallback's retry button clears errored queries before retrying.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary fallback={(err, retry) => <button onClick={() => { reset(); retry() }}>Retry</button>}>
<UserList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>Or imperatively: const { reset } = useQueryErrorResetBoundary().
useQueryClient()
Access the nearest QueryClient. Throws if no provider is mounted above the call site.
SSR dehydration
import { QueryClient, dehydrate, hydrate } from '@pyreon/query'
// Server:
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers })
const dehydratedState = dehydrate(queryClient)
// Client:
hydrate(queryClient, dehydratedState)Re-exports from @tanstack/query-core
Everything from @tanstack/query-core is re-exported, so @pyreon/query is your single import.
Runtime: QueryClient, QueryCache, MutationCache, dehydrate, hydrate, keepPreviousData, hashKey, isCancelledError, CancelledError, defaultShouldDehydrateQuery, defaultShouldDehydrateMutation.
Types: QueryKey, QueryFilters, MutationFilters, DehydratedState, FetchQueryOptions, InvalidateQueryFilters, InvalidateOptions, RefetchQueryFilters, RefetchOptions, QueryClientConfig.
Gotchas
useQuery/useInfiniteQuery/useQueries/useSuspenseQuerytake options as a FUNCTION, not an object. Reading signals inside is the mechanism for reactive refetches. Caught by the lint rule + MCP detectorpyreon/query-options-as-function(auto-fixable).useMutationis the exception — plain object.- Fields are independently subscribable —
query.data()does NOT re-run whenquery.isFetchingflips, and vice versa. Read only what you need. mutate()swallows errors into theerrorsignal. UsemutateAsync()if you need try/catch.useSuspenseQuery/useSuspenseInfiniteQueryrequire<QuerySuspense>— without it the narroweddata: Signal<TData>type lies (CAN be undefined during first render cycle).<QuerySuspense>children should be a function:{() => <UI />}. Plain JSX evaluates eagerly and defeats the suspense gate.useSubscriptiononMessageruns on every WebSocket frame — debounce cache invalidations for high-frequency streams.useSSE.parseis required for typed data — without it,data()is the rawstringevent payload.useSSE.initialLastEventIdis read once at mount — runtime changes need the reactiveurl(orreconnect()).- Observer subscriptions auto-clean on unmount via
onUnmount— no manual disposal needed.
Documentation
Full docs: pyreon.dev/docs/query (or docs/src/content/docs/query.md in this repo).
License
MIT
