Data Fetching & Caching
Fetch server data, cache it, keep it fresh, and mutate it — with @pyreon/query, Pyreon's adapter over TanStack Query. Every result field (data, error, isFetching) is an independent signal, so reading one doesn't subscribe a component to the others, and a refetch patches only the DOM that actually changed.
When to use it
You read data from a server/API and want caching, deduplication, background refetch, and stale-while-revalidate for free.
You mutate server state and want the affected queries to refetch automatically.
You need infinite/paginated lists, parallel queries, or Suspense-driven loading.
When not to use it
Purely client-side state with no server round-trip — reach for a plain
signal()or@pyreon/store.A one-off fetch with no caching needs —
@pyreon/hooks'useFetch({ data, error, isPending, refetch }) is the thin option.
Setup
Wrap your app once in a QueryClientProvider:
import { QueryClient, QueryClientProvider } from '@pyreon/query'
const client = new QueryClient()
export function App() {
return (
<QueryClientProvider client={client}>
<Posts />
</QueryClientProvider>
)
}Reading data
useQuery takes its options as a function — that is the single most important rule in this guide. The function re-runs when any signal it reads changes, so a query keyed on a signal refetches automatically:
// @check
import { useQuery } from '@pyreon/query'
import { signal } from '@pyreon/reactivity'
const userId = signal(1)
function User() {
const q = useQuery(() => ({
queryKey: ['user', userId()], // reads the signal → refetches on change
queryFn: () => fetch(`/api/users/${userId()}`).then((r) => r.json()),
}))
// Each field is its own signal — read them in reactive scopes (JSX).
return (
<div>
{() => (q.isPending() ? 'Loading…' : q.data()?.name)}
</div>
)
}Here is a live, runnable query that caches by key and refetches when the key changes:
Mutating data
useMutation is the exception to the function-options rule — its options are a plain object (mutations are imperative, nothing to track). Use invalidates to refetch affected queries on success:
import { useMutation } from '@pyreon/query'
function AddPost() {
const create = useMutation({
mutationFn: (title: string) =>
fetch('/api/posts', { method: 'POST', body: JSON.stringify({ title }) }),
invalidates: [['posts']], // refetch every query whose key starts with ['posts']
})
return <button onClick={() => create.mutate('Hello')}>Add</button>
}Other shapes
Parallel —
useQueries(() => [...]), or the named formdefineQueries({ user: () => opts, posts: () => opts })for a typed object instead of an array.Infinite —
useInfiniteQuery(() => ({ queryKey, queryFn, getNextPageParam })).Suspense —
useSuspenseQueryinside aQuerySuspenseboundary; pairQueryErrorResetBoundarywith a siblingErrorBoundaryso a fallback "retry" clears the errored query.Imperative access —
useQueryClient()for the nearest client; global spinners viauseIsFetching()/useIsMutating()(each returnsSignal<number>).
Common pitfalls
Passing
useQueryoptions as an object literal.useQuery({ queryKey, queryFn })captures the options once — the query never refetches when a key signal changes. Always wrap in a function:useQuery(() => ({ ... })). (@pyreon/lint'squery-options-as-functionrule and MCPvalidateboth flag this.)Reading a result field outside a reactive scope.
const d = q.data()at component-body top captures one value. Readq.data()inside JSX / aneffect/ acomputedso it tracks.Forgetting the provider.
useQueryClient()throws if noQueryClientProvideris mounted above.
Related
Query reference — every export with signatures