TanStack Query
One factory turns the typed client into finished queryOptions() and mutationOptions() objects, so a React component reads a collection in one line and never invents a cache key.
You have a typed client and a React app. This page is where the two meet: which builder to call, what it hands back, and what lands in the cache.
Install
Every starter already depends on it. To add it to an app of your own:
bun add @questpie/tanstack-query @tanstack/react-queryThe peers are @tanstack/react-query v5 and questpie. It is ESM only.
Build the proxy once
createQuestpieQueryOptions(client) returns an object of builder proxies.
Create it at module scope. The starters put it in this file for you.
import { createQuestpieQueryOptions } from "@questpie/tanstack-query";
import { client } from "@/lib/client";
export const q = createQuestpieQueryOptions(client);One component
A builder returns the whole options object. Pass it straight to the hook.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { q } from "@/lib/query";
export function Posts() {
const queryClient = useQueryClient();
const { data } = useQuery(
q.collections.posts.find({ where: { published: true }, limit: 10 }),
);
const create = useMutation({
...q.collections.posts.create(),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: q.key(["collections", "posts"]),
}),
});
return (
<>
<ul>
{data?.docs.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={() => create.mutate({ title: "Hello", slug: "hello" })}>
Add post
</button>
</>
);
}That renders up to ten published posts, typed from your schema. The button writes a row and invalidates every posts query, so the list refetches itself. You wrote no hook and no key.
What hangs off q
| Branch | What it builds |
|---|---|
q.collections.<name> | Reads and writes for one collection |
q.globals.<name> | Reads and writes for one global |
q.channels.<name> | Message and presence streams |
q.routes.<path>.<method> | query(), mutation() and key() |
q.custom | The same wrapping around a function of yours |
q.key(parts) | A key with your prefix already on the front |
Names are checked against your generated AppConfig. q.collections.newz is
a compile error, and so is a relation or column typo inside find().
Reads
q.collections.posts.find({
where: { published: true },
with: { author: true },
});
q.collections.posts.count({ where: { published: true } });
q.collections.posts.findOne({ where: { slug: "hello" } });
q.collections.posts.findVersions({ id: postId, limit: 20 });| Builder | data | Option set |
|---|---|---|
find(options?, live?) | the paginated envelope | the full find() set |
count(options?, live?) | number | where and includeDeleted |
findOne(options?) | the row, or null | the find() set minus paging |
findVersions({ id, limit?, offset? }) | the version rows | id is required |
`find()` hands back a page, `findOne()` hands back `null`
Read your rows off data.docs, never off data. findOne() resolves to
null when nothing matches, so guard it.
find, count and the global get take a second argument, and that is where
live data lives.
Writes
A mutation builder takes no arguments. It hands back mutationOptions(), and
the variables go to mutate().
const update = useMutation(q.collections.posts.update());
update.mutate({ id: postId, data: { published: true } });| Builder | mutate(variables) | Resolves to |
|---|---|---|
create() | the create input | the new row |
update() | { id, data } | the updated row |
delete() | { id } | { success, data } |
updateMany() | { where, data } | the rows it wrote |
update() and delete() work by id, never by filter. The rest of the writes
live on Mutations.
Globals
A global is a singleton, so get() resolves to the row and never to null.
const settings = useQuery(q.globals.siteSettings.get({ with: { logo: true } }));
const save = useMutation(q.globals.siteSettings.update());
save.mutate({ data: { siteName: "QUESTPIE" } });Global writes nest their payload
update() takes {data}, not the fields directly. revertToVersion() and
transitionStage() take {params}. The collection builders take the same
things unwrapped.
Routes
The path mirrors your route file and ends in the HTTP method, lowercased. Same
traversal as client.routes.
const stats = useQuery(
q.routes.dashboard.getStats.post.query({ period: "week" }),
);
const send = useMutation(q.routes.notifications.send.post.mutation());
send.mutate({ to: "user@example.com" });.query(input?) builds a query and .mutation() builds a mutation.
.key(input?) rebuilds the query key, for invalidation or prefetch.
Channels
const { data: messages = [] } = useQuery(
q.channels.chatRoom.subscription({ roomId }),
);
const { data: members = [] } = useQuery(
q.channels.chatRoom.presence({ roomId }),
);subscription() appends each typed message to an array. presence() replaces
its value with the newest roster. Both close when the query aborts. A channel
whose wire pattern declares params requires them, and presence() appears only
on channels that declare presence.
Where each topic lives
| Topic | Page |
|---|---|
| Every write builder and its variables | Mutations |
{ realtime: true } on find/count/get | Live queries |
| Key shapes and how to invalidate | Query keys |
keyPrefix, errorMap, locale, stage | Configuration |
| The client every builder wraps | SDK |
| Channel definitions and authorization | Channels |
| Keeping a live UI cheap | Reactive apps |
Date values through dehydration | Temporal values |
Next
Mutations is the rest of the writes: bulk, versions, workflow stages and soft delete.