Data with Convex
Reactive queries
Lesson 4 of 7
What you'll learn
- Write a Convex query function
- Read it on the client with
useQuery - Understand why the UI updates automatically
A Convex query is a pure read function. It receives a typed context, reads the database through an index, and returns plain data:
export const getCompleted = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const rows = await ctx.db
.query("progress")
.withIndex("by_user", (q) => q.eq("clerkUserId", identity.subject))
.collect();
return rows.map((r) => r.lessonId);
},
});
On the client you read it with the useQuery hook:
"use client";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
const completed = useQuery(api.progress.getCompleted);
The reactive part
Here's what makes Convex different: useQuery isn't a one-time fetch — it's a live subscription. Convex tracks exactly which rows your query read. When a mutation changes any of those rows, Convex pushes the new result to every subscribed client. Your component re-renders with fresh data automatically.
That's how, in this very app, finishing a lesson in one browser tab updates the progress bar in another tab instantly.
No plumbing
You don't write websocket code, polling loops, or cache-invalidation logic. Subscriptions and consistency are the database's job, not yours.
The exercise simulates a reactive store: subscribers are notified whenever the data changes. Run it and watch both "screens" update from a single write.
setData() notifies every subscriber — just like a Convex mutation pushing to every useQuery. Run it and read the log.
Reads are live. Next we'll make the writes that drive them.
Sign in to save your progress across devices.