BuildBot

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.

A reactive store in miniature

setData() notifies every subscriber — just like a Convex mutation pushing to every useQuery. Run it and read the log.

Loading editor…

Reads are live. Next we'll make the writes that drive them.

Sign in to save your progress across devices.