convex-realtime
waynesutton/convexskills
Build reactive apps with Convex subscriptions, optimistic updates, and cursor-based pagination
What is convex-realtime?
Convex Realtime provides patterns for building real-time reactive applications using automatic subscriptions, intelligent caching, optimistic updates, and cursor-based pagination. Use it when you need live data synchronization, instant UI feedback, and efficient data loading in React applications.
- Automatic real-time subscriptions via useQuery that update when data changes
- Optimistic updates to show changes immediately before server confirmation
- Smart caching that shares query results across components
- Cursor-based pagination with infinite scroll support
- Conditional queries that skip execution when parameters are unavailable
- Multiple independent subscriptions in a single component
How to install convex-realtime
npx skills add https://github.com/waynesutton/convexskills --skill convex-realtime- Convex project set up with React
- Convex backend queries and mutations defined
- Generated Convex API types via convex/_generated/api
How to use convex-realtime
- 1.Import useQuery, useMutation, and usePaginatedQuery from convex/react
- 2.Use useQuery to subscribe to real-time data in components
- 3.Call useMutation to trigger server mutations that auto-update subscriptions
- 4.Add withOptimisticUpdate to mutations to show changes before confirmation
- 5.Use usePaginatedQuery with paginationOptsValidator for cursor-based loading
- 6.Implement infinite scroll by observing a sentinel element and calling loadMore
Use cases
- Building task management apps with live task lists and instant toggles
- Creating chat applications with paginated message history and infinite scroll
- Displaying user dashboards with multiple real-time data streams
- Implementing optimistic UI patterns for instant feedback on mutations
- Loading large datasets efficiently with cursor-based pagination
- React developers building real-time applications
- Full-stack developers using Convex as their backend
- Teams needing live data synchronization without manual polling
- Developers implementing optimistic UI patterns
convex-realtime FAQ
Pass 'skip' as the second argument to useQuery instead of query parameters. For example: useQuery(api.users.get, userId ? { userId } : 'skip')
Convex automatically re-renders components with useQuery subscriptions that depend on the mutated data. No manual cache invalidation needed.
Use withOptimisticUpdate on a mutation to modify the local cache before the server responds. If the server request fails, Convex reverts to the server state.
No, use usePaginatedQuery instead. It provides status tracking (CanLoadMore, LoadingMore, Exhausted) and a loadMore function for cursor-based pagination.
No, useQuery automatically subscribes when the component mounts and unsubscribes when it unmounts. Convex handles all subscription lifecycle management.
Full instructions (SKILL.md)
Source of truth, from waynesutton/convexskills.
name: convex-realtime displayName: Convex Realtime description: Patterns for building reactive apps including subscription management, optimistic updates, cache behavior, and paginated queries with cursor-based loading version: 1.0.0 author: Convex tags: [convex, realtime, subscriptions, optimistic-updates, pagination]
Convex Realtime
Build reactive applications with Convex's real-time subscriptions, optimistic updates, intelligent caching, and cursor-based pagination.
Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
- Primary: https://docs.convex.dev/client/react
- Optimistic Updates: https://docs.convex.dev/client/react/optimistic-updates
- Pagination: https://docs.convex.dev/database/pagination
- For broader context: https://docs.convex.dev/llms.txt
Instructions
How Convex Realtime Works
- Automatic Subscriptions - useQuery creates a subscription that updates automatically
- Smart Caching - Query results are cached and shared across components
- Consistency - All subscriptions see a consistent view of the database
- Efficient Updates - Only re-renders when relevant data changes
Basic Subscriptions
// 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>
);
}
Conditional Queries
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>;
}
Mutations with Real-time Updates
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>
);
}
Optimistic Updates
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>
);
}
Optimistic Updates for Lists
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,
]);
}
}
);
}
Cursor-Based Pagination
// 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>
);
}
Infinite Scroll Pattern
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>
);
}
Multiple Subscriptions
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>
);
}
Examples
Real-time Chat Application
// 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>
);
}
Best Practices
- Never run
npx convex deployunless explicitly instructed - Never run any git commands unless explicitly instructed
- Use "skip" for conditional queries instead of conditionally calling hooks
- Implement optimistic updates for better perceived performance
- Use usePaginatedQuery for large datasets
- Handle undefined state (loading) explicitly
- Avoid unnecessary re-renders by memoizing derived data
Common Pitfalls
- Conditional hook calls - Use "skip" instead of if statements
- Not handling loading state - Always check for undefined
- Missing optimistic update rollback - Optimistic updates auto-rollback on error
- Over-fetching with pagination - Use appropriate page sizes
- Ignoring subscription cleanup - React handles this automatically
References
- Convex Documentation: https://docs.convex.dev/
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
- React Client: https://docs.convex.dev/client/react
- Optimistic Updates: https://docs.convex.dev/client/react/optimistic-updates
- Pagination: https://docs.convex.dev/database/pagination
Related skills
More from waynesutton/convexskills and the wider catalog.
convex
Umbrella skill routing to specialized Convex development patterns and tools.
convex-best-practices
Guidelines for building production-ready Convex apps with best practices for functions, queries, validation, and error handling.
convex-functions
Write Convex queries, mutations, actions, and HTTP endpoints with validation, error handling, and best practices.
convex-schema-validator
Define and validate Convex database schemas with typing, indexes, and migration strategies.
convex-cron-jobs
Schedule recurring background tasks in Convex with interval and cron expression patterns.
convex-http-actions
Build HTTP endpoints for webhooks, external APIs, and custom routes in Convex.