@pyreon/query
v0.19.0
Published
Pyreon adapter for TanStack Query
Downloads
1,322
Readme
@pyreon/query
Pyreon adapter for TanStack Query. Reactive useQuery, useMutation, useInfiniteQuery, and Suspense integration with fine-grained signal updates.
Install
bun add @pyreon/query @tanstack/query-coreQuick 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</p>
return <h1>{query.data()?.name}</h1>
}
}
const App = () => (
<QueryClientProvider client={queryClient}>
<UserProfile id="1" />
</QueryClientProvider>
)API
QueryClientProvider
Provide a QueryClient to the component tree.
| Parameter | Type | Description |
| --------- | ------------- | ------------------------------ |
| client | QueryClient | TanStack Query client instance |
useQueryClient()
Access the QueryClient from the nearest QueryClientProvider.
Returns: QueryClient
useQuery(options)
Subscribe to a query with fine-grained reactive signals. Options are passed as a function so reactive values (e.g. signal-based query keys) trigger automatic refetches.
| Parameter | Type | Description |
| --------- | ---------------------------- | -------------------------------- |
| options | () => QueryObserverOptions | Function returning query options |
Returns: UseQueryResult<TData, TError> with:
| Property | Type | Description |
| ------------ | ------------------------------------------- | ----------------------- |
| result | Signal<QueryObserverResult> | Full observer result |
| data | Signal<TData \| undefined> | Query data |
| error | Signal<TError \| null> | Query error |
| status | Signal<"pending" \| "error" \| "success"> | Query status |
| isPending | Signal<boolean> | No data yet |
| isLoading | Signal<boolean> | First fetch in progress |
| isFetching | Signal<boolean> | Any fetch in progress |
| isError | Signal<boolean> | Query errored |
| isSuccess | Signal<boolean> | Query succeeded |
| refetch() | () => Promise<QueryObserverResult> | Trigger manual refetch |
const userId = signal(1)
const query = useQuery(() => ({
queryKey: ['user', userId()],
queryFn: () => fetchUser(userId()),
}))
// Changing userId triggers automatic refetchuseMutation(options)
Run a mutation with reactive status signals.
| Parameter | Type | Description |
| --------- | ------------------------- | --------------- |
| options | MutationObserverOptions | Mutation config |
Returns: UseMutationResult<TData, TError, TVariables, TContext> with:
| Property | Type | Description |
| -------------------------- | ----------------------------------------------------- | -------------------------- |
| data | Signal<TData \| undefined> | Mutation result |
| error | Signal<TError \| null> | Mutation error |
| status | Signal<"idle" \| "pending" \| "success" \| "error"> | Status |
| isPending | Signal<boolean> | Mutation in progress |
| isSuccess | Signal<boolean> | Mutation succeeded |
| isError | Signal<boolean> | Mutation errored |
| isIdle | Signal<boolean> | Not yet fired |
| mutate(vars, opts?) | Function | Fire-and-forget mutation |
| mutateAsync(vars, opts?) | Function | Promise-returning mutation |
| reset() | () => void | Reset to idle state |
const mutation = useMutation({
mutationFn: (data: { title: string }) =>
fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }).then((r) => r.json()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
})
mutation.mutate({ title: 'New Post' })useInfiniteQuery(options)
Paginated/infinite query with the same fine-grained signal pattern as useQuery.
| Parameter | Type | Description |
| --------- | ------------------------------------ | -------------------------- |
| options | () => InfiniteQueryObserverOptions | Function returning options |
Returns: UseInfiniteQueryResult<TData, TError> — same shape as UseQueryResult.
useQueries(options)
Run multiple queries in parallel.
| Parameter | Type | Description |
| --------- | ------------------- | ---------------------- |
| options | UseQueriesOptions | Array of query configs |
Returns: Array of UseQueryResult objects.
useSuspenseQuery(options) / useSuspenseInfiniteQuery(options)
Suspense-enabled queries. Data is guaranteed non-undefined after the suspense boundary resolves.
| Property | Type | Description |
| -------- | --------------- | ------------------------------ |
| data | Signal<TData> | Always defined (non-undefined) |
function UserList() {
const query = useSuspenseQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
return () => (
<ul>
{query.data().map((u) => (
<li>{u.name}</li>
))}
</ul>
)
}QuerySuspense
Suspense wrapper component with built-in error handling.
| Parameter | Type | Description |
| ---------- | ------------ | ---------------- |
| fallback | VNodeChild | Loading fallback |
| children | VNodeChild | Content |
<QuerySuspense fallback={<p>Loading...</p>}>
<UserList />
</QuerySuspense>QueryErrorResetBoundary / useQueryErrorResetBoundary()
Error boundary for resetting query errors on retry. Wrap QueryErrorResetBoundary around a QuerySuspense + ErrorBoundary pair so a sibling ErrorBoundary recovery automatically clears errored queries. useQueryErrorResetBoundary() returns { reset } for imperative access from inside an ErrorBoundary fallback outside the render-prop form.
useSubscription(options)
Reactive WebSocket with auto-reconnect and QueryClient cache integration. onMessage receives the active QueryClient so push updates can invalidate or directly patch cached queries. Exponential backoff on reconnect (default 1s doubling, max 10 attempts). url and enabled may be signals.
Returns: UseSubscriptionResult with status (signal), send(data), close(), reconnect().
const sub = useSubscription({
url: 'wss://api.example.com/feed',
onMessage: (event, client) => {
if (JSON.parse(event.data).type === 'post-created') {
client.invalidateQueries({ queryKey: ['posts'] })
}
},
})useSSE(options)
Reactive Server-Sent Events hook — same pattern as useSubscription but read-only. parse deserializes each event; events filters named event types. Updates lastEventId() on every incoming id field so consumers can persist + resume across remount.
Returns: UseSSEResult<T> with data (signal of last parsed message), status (signal), error (signal), lastEventId(), readyState(), close(), reconnect().
const sse = useSSE({
url: '/api/events',
parse: JSON.parse,
onMessage: (data, queryClient) => {
if (data.type === 'order-updated') {
queryClient.invalidateQueries({ queryKey: ['orders'] })
}
},
})Resuming across remount. Browser EventSource only auto-resumes (sends the Last-Event-ID header) within a single instance lifetime. A new instance — including a fresh hook call after navigation — starts from scratch. To persist the resume point, pair useStorage with the initialLastEventId option and put 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 to the seed accessor are intentionally ignored — use the reactive url (or call reconnect()) for runtime overrides.
useIsFetching(filters?) / useIsMutating(filters?)
Global counters of active queries/mutations as reactive signals.
| Parameter | Type | Description |
| --------- | ---------------------------------- | -------------------------------- |
| filters | QueryFilters / MutationFilters | Optional filters to narrow scope |
Returns: Signal<number>
const fetching = useIsFetching()
// fetching() => number of active queriesPatterns
SSR Dehydration
import { QueryClient, dehydrate, hydrate } from '@pyreon/query'
// Server: prefetch and serialize
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers })
const dehydratedState = dehydrate(queryClient)
// Client: restore cache
hydrate(queryClient, dehydratedState)Reactive Query Keys
Options are a function, so reading signals inside auto-tracks dependencies.
const filter = signal('active')
const query = useQuery(() => ({
queryKey: ['todos', filter()],
queryFn: () => fetchTodos(filter()),
}))
// Changing filter() triggers a new fetchRe-exports from @tanstack/query-core
Runtime: QueryClient, QueryCache, MutationCache, dehydrate, hydrate, keepPreviousData, hashKey, isCancelledError, CancelledError, defaultShouldDehydrateQuery, defaultShouldDehydrateMutation
Types: QueryKey, QueryFilters, MutationFilters, DehydratedState, FetchQueryOptions, InvalidateQueryFilters, InvalidateOptions, RefetchQueryFilters, RefetchOptions, QueryClientConfig
Gotchas
- Each field on
UseQueryResultis an independent signal. Readingquery.data()does not re-run whenisFetchingchanges, and vice versa. useQueryoptions must be a function() => opts, not a plain object. This is required for reactive option tracking. Changing a signal inside the function re-evaluates options + refetches.useMutationoptions are a plain object (not a function) — mutations are imperative, there are no reactive queryKeys to re-evaluate, so the function-wrapper overhead would add no value.mutate()swallows errors into theerrorsignal. UsemutateAsync()if you need try/catch.useQueriesaccepts a function too — passuseQueries(() => ids().map(...))for reactive query lists. A static array loses tracking.useSuspenseQuery/useSuspenseInfiniteQueryrequire aQuerySuspenseboundary — without it, the narroweddata: Signal<TData>type lies:data()CAN be undefined during the first render cycle.QuerySuspensechildren must be a function{() => <UI />}— plain JSX children evaluate eagerly, defeating the Suspense gate.useSubscriptiononMessageruns on every WebSocket frame — debounce cache invalidations for high-frequency streams.useSSErequiresparsefor typed data — without it,data()is rawstring(the event payload as-is).- Observer subscriptions are cleaned up automatically on component unmount via
onUnmount.
