.agents/skills/convex-realtime/SKILL.md
Patterns for building reactive apps including subscription management, optimistic updates, cache behavior, and paginated queries with cursor-based loading
npx skillsauth add nakafaai/nakafa.com 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.
Build reactive applications with Convex's real-time subscriptions, optimistic updates, intelligent caching, and cursor-based pagination.
Before implementing, do not assume; fetch the latest documentation:
// React component with real-time data
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TaskList({ userId }: { userId: Id<"users"> }) {
// Automatically subscribes and updates in real-time
const tasks = useQuery(api.tasks.list, { userId });
if (tasks === undefined) {
return <div>Loading...</div>;
}
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function UserProfile({ userId }: { userId: Id<"users"> | null }) {
// Skip query when userId is null
const user = useQuery(
api.users.get,
userId ? { userId } : "skip"
);
if (userId === null) {
return <div>Select a user</div>;
}
if (user === undefined) {
return <div>Loading...</div>;
}
return <div>{user.name}</div>;
}
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TaskManager({ userId }: { userId: Id<"users"> }) {
const tasks = useQuery(api.tasks.list, { userId });
const createTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.toggle);
const handleCreate = async (title: string) => {
// Mutation triggers automatic re-render when data changes
await createTask({ title, userId });
};
const handleToggle = async (taskId: Id<"tasks">) => {
await toggleTask({ taskId });
};
return (
<div>
<button onClick={() => handleCreate("New Task")}>Add Task</button>
<ul>
{tasks?.map((task) => (
<li key={task._id} onClick={() => handleToggle(task._id)}>
{task.completed ? "✓" : "○"} {task.title}
</li>
))}
</ul>
</div>
);
}
Show changes immediately before server confirmation:
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function TaskItem({ task }: { task: Task }) {
const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const { taskId } = args;
const currentValue = localStore.getQuery(api.tasks.get, { taskId });
if (currentValue !== undefined) {
localStore.setQuery(api.tasks.get, { taskId }, {
...currentValue,
completed: !currentValue.completed,
});
}
}
);
return (
<div onClick={() => toggleTask({ taskId: task._id })}>
{task.completed ? "✓" : "○"} {task.title}
</div>
);
}
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function useCreateTask(userId: Id<"users">) {
return useMutation(api.tasks.create).withOptimisticUpdate(
(localStore, args) => {
const { title, userId } = args;
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
if (currentTasks !== undefined) {
// Add optimistic task to the list
const optimisticTask = {
_id: crypto.randomUUID() as Id<"tasks">,
_creationTime: Date.now(),
title,
userId,
completed: false,
};
localStore.setQuery(api.tasks.list, { userId }, [
optimisticTask,
...currentTasks,
]);
}
}
);
}
// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
export const listPaginated = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
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
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{ channelId },
{ initialNumItems: 20 }
);
return (
<div>
{results.map((message) => (
<div key={message._id}>{message.content}</div>
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Load More</button>
)}
{status === "LoadingMore" && <div>Loading...</div>}
{status === "Exhausted" && <div>No more messages</div>}
</div>
);
}
import { usePaginatedQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { api } from "../convex/_generated/api";
function InfiniteMessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{ channelId },
{ initialNumItems: 20 }
);
const observerRef = useRef<IntersectionObserver>();
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && status === "CanLoadMore") {
loadMore(20);
}
});
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => observerRef.current?.disconnect();
}, [status, loadMore]);
return (
<div>
{results.map((message) => (
<div key={message._id}>{message.content}</div>
))}
<div ref={loadMoreRef} style={{ height: 1 }} />
{status === "LoadingMore" && <div>Loading...</div>}
</div>
);
}
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function Dashboard({ userId }: { userId: Id<"users"> }) {
// Multiple subscriptions update independently
const user = useQuery(api.users.get, { userId });
const tasks = useQuery(api.tasks.list, { userId });
const notifications = useQuery(api.notifications.unread, { userId });
const isLoading = user === undefined ||
tasks === undefined ||
notifications === undefined;
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>You have {tasks.length} tasks</p>
<p>{notifications.length} unread notifications</p>
</div>
);
}
// convex/messages.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { channelId: v.id("channels") },
returns: v.array(v.object({
_id: v.id("messages"),
_creationTime: v.number(),
content: v.string(),
authorId: v.id("users"),
authorName: v.string(),
})),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(100);
// Enrich with author names
return Promise.all(
messages.map(async (msg) => {
const author = await ctx.db.get(msg.authorId);
return {
...msg,
authorName: author?.name ?? "Unknown",
};
})
);
},
});
export const send = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.id("messages"),
handler: async (ctx, args) => {
return await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
},
});
// ChatRoom.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState, useRef, useEffect } from "react";
function ChatRoom({ channelId, userId }: Props) {
const messages = useQuery(api.messages.list, { channelId });
const sendMessage = useMutation(api.messages.send);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
await sendMessage({
channelId,
authorId: userId,
content: input.trim(),
});
setInput("");
};
return (
<div className="chat-room">
<div className="messages">
{messages?.map((msg) => (
<div key={msg._id} className="message">
<strong>{msg.authorName}:</strong> {msg.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSend}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
npx convex deploy unless explicitly instructedtools
Next.js 16 Cache Components - PPR, use cache directive, cacheLife, cacheTag, updateTag
development
Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
development
Create educational content (MDX) and exercises for Nakafa platform. Use when creating or editing subject materials, exercises, questions, answers/explanations, or any educational content in MDX format.
testing
When the user wants to apply psychological principles, mental models, or behavioral science to marketing. Also use when the user mentions 'psychology,' 'mental models,' 'cognitive bias,' 'persuasion,' 'behavioral science,' 'why people buy,' 'decision-making,' 'consumer behavior,' 'anchoring,' 'social proof,' 'scarcity,' 'loss aversion,' 'framing,' or 'nudge.' Use this whenever someone wants to understand or leverage how people think and make decisions in a marketing context.