Day 3 — Queries, mutations, and the reactivity payoff
The one idea. You never write subscription code. You write a query, and the fact that it stays current is a consequence of the query being deterministic — not a feature bolted on top.
Before you watch: this video is from December 2024 and predates the table-name document API. Take the workflow from it, take the syntax from
../reference/api-cheatsheet.md.
#1. Anatomy of a function
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { ownerId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db
.query("links")
.withIndex("by_owner", (q) => q.eq("ownerId", args.ownerId))
.order("desc")
.take(50);
},
});Three parts, and each is load-bearing:
argsis not documentation. It is the boundary check on a public HTTP endpoint. The docs are blunt: every public function must validate its arguments, because "argument validators help ensure you're getting the traffic you expect."returnsis optional, and worth adding on anything a client depends on — it catches the day you change a handler and forget who was reading the old shape. Be aware it must describe the value exactly: full documents also carry_idand_creationTime, so a hand-written object validator that omits them will reject its own output. That footgun is why it's omitted above.handlergetsctx(db, auth, storage, scheduler) and the validatedargs.
#2. Mutations are transactions
export const archive = mutation({
args: { linkId: v.id("links") },
handler: async (ctx, args) => {
const link = await ctx.db.get("links", args.linkId);
if (link === null) throw new Error("Not found");
await ctx.db.patch("links", args.linkId, { archived: true });
await ctx.db.insert("auditLog", { action: "archive", linkId: args.linkId });
},
});Both writes land or neither does. If the insert throws, the patch is rolled back — you
will never find an archived link with no audit entry. You did not open a transaction, set
an isolation level, or write a rollback path. The function body is the transaction
boundary.
This is also why a mutation can be retried safely: re-running it from the start is always legal, because a failed attempt left nothing behind.
#3. The client side
const links = useQuery(api.links.list, { ownerId });
const archive = useMutation(api.links.archive);
if (links === undefined) return <Skeleton />; // loading — see Day 1 §4
return links.map((l) => (
<button key={l._id} onClick={() => archive({ linkId: l._id })}>{l.url}</button>
));That is all of it. No useEffect. No refetch after the mutation. No cache key to
invalidate. When archive commits, Convex knows list read the rows that changed, re-runs
it, and pushes the new array to every client subscribed to it.
The story. The difference between a mailing list and a doorbell. With fetch +
useEffect you are standing at the door asking "post yet?" every five seconds, and mostly
the answer is no. With Convex you signed up once and the postman rings when something
actually arrives. The saving isn't the questions — it's that you no longer own the asking
machinery: the interval, the backoff, the "did this component unmount" check, the
deduplication of four components asking for the same thing.
#4. The demo that makes it click
Open your app in two browser windows, side by side. Add a link in one.
It appears in the other. You wrote no realtime code. Not "you used a realtime library" — there is no subscription in your codebase to point at.
Now do it a third way: leave both windows open and edit the row directly in the Convex dashboard. Both windows update. The reactivity is a property of the database, not of a code path in your app.
Sit with that for a second. On Day 1 you wrote down what Convex claims to remove. This is the claim being cashed.
#5. Pagination, briefly
const { results, status, loadMore } = usePaginatedQuery(
api.links.paginatedList, { ownerId }, { initialNumItems: 20 }
);Reach for this the moment a list could grow without bound. .take(50) is a load-bearing
guess; pagination is a decision. Day 7 goes deeper.
#Quick check 1
After calling
archive(...), where do you put the code that refetches the list?Answer
Nowhere. There is nothing to write. The mutation commits, Convex re-runs the affected queries and pushes new results. If you find yourself reaching for a manual refetch, you are still holding a REST-shaped assumption.
#Quick check 2
Your mutation patches a document and then throws. What is in the database?
Answer
The state from before the mutation ran. The whole handler is one transaction, so the patch rolls back. This is also why mutations can be retried automatically — a failed attempt is guaranteed to have left nothing behind.
#Quick check 3
Two components on the same page both call
useQuery(api.links.list, { ownerId })with identical arguments. How many subscriptions, and how many re-runs on a write?Answer
Conceptually one — the query result is identified by function + arguments, so identical calls share it, and one write produces one re-run feeding both components. This is why query arguments should be stable values, not freshly-built objects with a
Date.now()in them (Day 1's determinism rule, arriving as a practical caching concern).
#Build task
Turn yesterday's schema into a working link saver.
links.list(indexed,.take(50)),links.create,links.archive,links.remove.- A React UI: list, add form, archive button.
- Handle
undefinedexplicitly with a skeleton. Then deliberately delete that check and watch it crash, so you recognise the error when it finds you later. - Two windows. Confirm live updates. Then edit a row in the dashboard and confirm the UI follows.
- Make
archivewrite toauditLogand thenthrowon purpose. Check the dashboard: confirm neither write landed.
Step 5 is the one people skip. It is the one that makes "it's a transaction" stop being a phrase you repeat and start being a thing you've seen.
#Checkpoint
- Why does
argsvalidation matter more here than in a typical internal helper function? - A mutation does two writes and fails on the second. Database state?
- What is the complete list of code you write to keep a list live after a mutation?
- Why is
undefinedthe loading signal rather than aisLoadingboolean? - When does
.take(50)stop being good enough?
#Cheat strip
query({ args, returns?, handler }) mutation({ args, returns?, handler })
useQuery(api.mod.fn, args) → undefined while loading
useMutation(api.mod.fn) → async fn
usePaginatedQuery(api.mod.fn, args, { initialNumItems }) → {results, status, loadMore}
mutation body === transaction boundary (all-or-nothing, auto-retryable)
after a mutation: write NOTHING to refresh queries#Tomorrow
Day 4 leaves the sealed room. You call a real third-party API, and everything comfortable about today — transactions, retries, reactivity — stops applying.