skills/convex-realtime/SKILL.md
Realtime subscriptions and optimistic updates in Convex. Use when implementing live data updates, optimistic UI, pagination with realtime, presence indicators, typing indicators, or any feature requiring instant data synchronization.
npx skillsauth add aaronvanston/skills-convex convex-realtimeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Queries in Convex automatically subscribe to updates:
// React component - automatically updates when data changes
function TaskList({ userId }: { userId: Id<"users"> }) {
const tasks = useQuery(api.tasks.list, { userId });
if (tasks === undefined) return <Loading />;
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function AddTask() {
const addTask = useMutation(api.tasks.create).withOptimisticUpdate(
(localStore, args) => {
const { title, userId } = args;
// Get current tasks from local cache
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
if (currentTasks === undefined) return;
// Add optimistic task
const optimisticTask = {
_id: crypto.randomUUID() as Id<"tasks">,
_creationTime: Date.now(),
title,
userId,
completed: false,
};
// Update local cache immediately
localStore.setQuery(api.tasks.list, { userId }, [
optimisticTask,
...currentTasks,
]);
}
);
return (
<button onClick={() => addTask({ title: "New Task", userId })}>
Add Task
</button>
);
}
const deleteTask = useMutation(api.tasks.remove).withOptimisticUpdate(
(localStore, args) => {
const { taskId, userId } = args;
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
if (currentTasks === undefined) return;
// Remove task from local cache
localStore.setQuery(
api.tasks.list,
{ userId },
currentTasks.filter((t) => t._id !== taskId)
);
}
);
const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const { taskId, userId } = args;
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
if (currentTasks === undefined) return;
// Toggle completed status locally
localStore.setQuery(
api.tasks.list,
{ userId },
currentTasks.map((t) =>
t._id === taskId ? { ...t, completed: !t.completed } : t
)
);
}
);
// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
export const list = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
returns: v.object({
page: v.array(v.object({
_id: v.id("messages"),
_creationTime: v.number(),
content: v.string(),
authorId: v.id("users"),
})),
isDone: v.boolean(),
continueCursor: v.string(),
}),
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});
// React component with pagination
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.list,
{ channelId },
{ initialNumItems: 25 }
);
return (
<div>
{results.map((message) => (
<Message key={message._id} message={message} />
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(25)}>Load More</button>
)}
{status === "LoadingMore" && <Loading />}
</div>
);
}
// convex/schema.ts
export default defineSchema({
presence: defineTable({
odcumentId: v.string(),
odcumentType: v.string(),
lastSeen: v.number(),
})
.index("by_user", ["userId"])
.index("by_document", ["documentId", "documentType"]),
});
// convex/presence.ts
export const heartbeat = mutation({
args: {
documentId: v.string(),
documentType: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const existing = await ctx.db
.query("presence")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.filter((q) =>
q.and(
q.eq(q.field("documentId"), args.documentId),
q.eq(q.field("documentType"), args.documentType)
)
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, { lastSeen: Date.now() });
} else {
await ctx.db.insert("presence", {
userId: identity.subject,
documentId: args.documentId,
documentType: args.documentType,
lastSeen: Date.now(),
});
}
return null;
},
});
export const getActive = query({
args: {
documentId: v.string(),
documentType: v.string(),
},
returns: v.array(v.object({
userId: v.string(),
lastSeen: v.number(),
})),
handler: async (ctx, args) => {
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
return await ctx.db
.query("presence")
.withIndex("by_document", (q) =>
q.eq("documentId", args.documentId).eq("documentType", args.documentType)
)
.filter((q) => q.gt(q.field("lastSeen"), fiveMinutesAgo))
.collect();
},
});
function usePresence(documentId: string, documentType: string) {
const heartbeat = useMutation(api.presence.heartbeat);
const activeUsers = useQuery(api.presence.getActive, {
documentId,
documentType,
});
useEffect(() => {
// Send heartbeat every 30 seconds
const interval = setInterval(() => {
heartbeat({ documentId, documentType });
}, 30000);
// Initial heartbeat
heartbeat({ documentId, documentType });
return () => clearInterval(interval);
}, [documentId, documentType, heartbeat]);
return activeUsers ?? [];
}
// convex/typing.ts
export const setTyping = mutation({
args: { channelId: v.id("channels"), isTyping: v.boolean() },
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
const existing = await ctx.db
.query("typing")
.withIndex("by_channel_user", (q) =>
q.eq("channelId", args.channelId).eq("userId", identity.subject)
)
.unique();
if (args.isTyping) {
if (existing) {
await ctx.db.patch(existing._id, { updatedAt: Date.now() });
} else {
await ctx.db.insert("typing", {
channelId: args.channelId,
userId: identity.subject,
updatedAt: Date.now(),
});
}
} else if (existing) {
await ctx.db.delete(existing._id);
}
return null;
},
});
export const getTyping = query({
args: { channelId: v.id("channels") },
returns: v.array(v.string()),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
const tenSecondsAgo = Date.now() - 10000;
const typing = await ctx.db
.query("typing")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.filter((q) => q.gt(q.field("updatedAt"), tenSecondsAgo))
.collect();
// Exclude current user
return typing
.filter((t) => t.userId !== identity?.subject)
.map((t) => t.userId);
},
});
function UserProfile({ userId }: { userId: Id<"users"> | null }) {
// Query only runs when userId is not null
const user = useQuery(
api.users.get,
userId ? { userId } : "skip"
);
if (userId === null) return <GuestView />;
if (user === undefined) return <Loading />;
return <ProfileView user={user} />;
}
undefined (loading) vs null (not found)tools
Security best practices for Convex functions including ConvexError handling, argument/return validation, authentication helpers, access control, rate limiting, and internal functions. Use when writing public queries/mutations/actions, implementing authentication, adding authorization checks, handling errors, or reviewing Convex functions for security.
development
Comprehensive Convex code review checklist for production readiness. Use when auditing a Convex codebase before deployment, reviewing pull requests, or checking for security and performance issues in Convex functions.
data-ai
Best practices for Convex database queries, indexes, and filtering. Use when writing or reviewing database queries in Convex, working with `.filter()`, `.collect()`, `.withIndex()`, defining indexes in schema.ts, or optimizing query performance.
tools
Code organization patterns and TypeScript best practices for Convex. Use when structuring a Convex project, writing helper functions, defining schemas, working with types like QueryCtx/MutationCtx/ActionCtx, or organizing code in a convex/model directory.