avoid-feature-creep
Keeps a change scoped to what was asked. Flags extra features, refactors, and 'while we are here' edits, and moves them to a…
Builds reactive UIs on Convex subscriptions: useQuery patterns, optimistic updates, pagination that stays live, presence, and avoiding subscription churn. Use when wiring the frontend to Convex, when data does not update live, or when a page rerenders too much.
$ npx -y skills add waynesutton/builder-skills --skill convex-realtime --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/convex-realtimeContext preview
The summary Claude sees to decide when to auto-load this skill.
Builds reactive UIs on Convex subscriptions: useQuery patterns, optimistic updates, pagination that stays live, presence, and avoiding subscription churn. Use when wiring the frontend to Convex, when data does not update live, or when a page rerenders too much.
name: convex-realtime description: Builds reactive UIs on Convex subscriptions: useQuery patterns, optimistic updates, pagination that stays live, presence, and avoiding subscription churn. Use when wiring the frontend to Convex, when data does not update live, or when a page rerenders too much.
Every `useQuery` is a live subscription. The one rule: keep the set of documents a query reads small and indexed, because that read set decides when the query reruns.
`useQuery` opens a subscription over the client's websocket. The server runs the query, records its read set (every document and index range it touched), and pushes the result. When a mutation commits a write that overlaps that read set, the server reruns the query and pushes the new result. Nothing overlaps, nothing reruns. Results are cached per function and args, so two components calling `useQuery(api.tasks.list, { userId })` share one subscription. All active subscriptions update together at the same database timestamp, so the UI never shows a half applied mutation. Queries must be deterministic for this to hold, which is why `Date.now()`, `Math.random()`, and `fetch` are not allowed inside them.
`undefined` means loading. Pass `"skip"` instead of wrapping the hook in a condition.
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function TaskList({ userId }: { userId: Id<"users"> | null }) {
const tasks = useQuery(api.tasks.list, userId ? { userId } : "skip");
if (userId === null) return <p>Select a user</p>;
if (tasks === undefined) return <p>Loading</p>;
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}Backend queries return validated data and read through an index:
// convex/tasks.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { userId: v.id("users") },
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
completed: v.boolean(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.take(100);
},
});A plain `useMutation` call is enough for most UI. The subscription refreshes when the write commits, usually within a round trip. Add an optimistic update when that round trip is visible: toggles, reorders, chat sends.
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function useToggleTask(userId: Id<"users">) {
return useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.tasks.list, { userId });
if (current === undefined) return;
localStore.setQuery(
api.tasks.list,
{ userId },
current.map((task) =>
task._id === args.taskId
? { ...task, completed: !task.completed }
: task,
),
);
},
);
}Inserting into a list works the same way. Build a temporary document with a placeholder `_id` and `_creationTime: Date.now()` (allowed here, this runs on the client), prepend it, and let the server result replace it. If the mutation throws, Convex rolls the local store back for you. Update every query the mutation affects, not just the one on screen, or the others will look stale until the server responds.
// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
export const listByChannel = query({
args: { channelId: v.id("channels"), paginationOpts: paginationOptsValidator },
// Return shape is fixed by .paginate(): { page, isDone, continueCursor }
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listByChannel,
{ channelId },
{ initialNumItems: 30 },
);
return (
<div>
{results.map((m) => (
<p key={m._id}>{m.body}</p>
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(30)}>Load more</button>
)}
{status === "LoadingMore" && <p>Loading</p>}
</div>
);
}Each loaded page is its own subscription, so inserts and deletes anywhere in the list show up without refetching. `status` is `LoadingFirstPage`, `CanLoadMore`, `LoadingMore`, or `Exhausted`. For infinite scroll, call `loadMore` from an `IntersectionObserver` on a sentinel element when `status === "CanLoadMore"`. Cursors come from `.paginate()` only. Do not build offset pagination by slicing a `.collect()` result.
Several `useQuery` calls in one component already load in parallel and stay consistent with each other. Reach for `useQueries` when the set of querie
Agent skills for builders shipping Convex apps. By Wayne Sutton. Seventeen skills.
Keeps a change scoped to what was asked. Flags extra features, refactors, and 'while we are here' edits, and moves them to a…
Builds AI agents on the Convex agent component: threads, messages, tools that call queries and mutations, streaming, RAG with…
Production patterns for Convex apps and the rules the @convex-dev/eslint-plugin enforces: validators, indexes, idempotent…
Creates reusable Convex components with defineComponent, a clean client wrapper, their own schema, and an npm publish setup. Use…
Schedules work in Convex: cron jobs in convex/crons.ts, one off scheduled functions with runAfter and runAt, batching large jobs,…
Handles files in Convex: upload URLs, storing blobs from actions, serving with getUrl or an HTTP action, metadata from the…