◆ Convex Stack Bootcamp
Day 3 of 7

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.

Time: ~90 min · Video: The Complete Convex Crash Course (38:34, Web Dev Cody) · You ship: live multi-window CRUD with no realtime code.

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:

#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.

  1. links.list (indexed, .take(50)), links.create, links.archive, links.remove.
  2. A React UI: list, add form, archive button.
  3. Handle undefined explicitly with a skeleton. Then deliberately delete that check and watch it crash, so you recognise the error when it finds you later.
  4. Two windows. Confirm live updates. Then edit a row in the dashboard and confirm the UI follows.
  5. Make archive write to auditLog and then throw on 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

  1. Why does args validation matter more here than in a typical internal helper function?
  2. A mutation does two writes and fails on the second. Database state?
  3. What is the complete list of code you write to keep a list live after a mutation?
  4. Why is undefined the loading signal rather than a isLoading boolean?
  5. 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.

Stored in this browser only.