Convex React client - hooks, real-time updates, optimistic updates, pagination, and UI patterns
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Complete React client guidelines for Convex, including hooks, real-time updates, optimistic updates, and best practices for building reactive UIs.
import React, { useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export default function App() {
const messages = useQuery(api.messages.list) || [];
const [newMessageText, setNewMessageText] = useState("");
const sendMessage = useMutation(api.messages.send);
const [name] = useState(() => "User " + Math.floor(Math.random() * 10000));
async function handleSendMessage(event: React.FormEvent) {
event.preventDefault();
await sendMessage({ body: newMessageText, author: name });
setNewMessageText("");
}
return (
<main>
<h1>Convex Chat</h1>
<p className="badge">
<span>{name}</span>
</p>
<ul>
{messages.map((message) => (
<li key={message._id}>
<span>{message.author}:</span>
<span>{message.body}</span>
<span>{new Date(message._creationTime).toLocaleTimeString()}</span>
</li>
))}
</ul>
<form onSubmit={handleSendMessage}>
<input
value={newMessageText}
onChange={(event) => setNewMessageText(event.target.value)}
placeholder="Write a message..."
/>
<button type="submit" disabled={!newMessageText}>
Send
</button>
</form>
</main>
);
}
The useQuery() hook is live-updating! It causes the React component to rerender automatically when data changes. Convex is a perfect fit for collaborative, live-updating websites.
undefined - Query is loadingnull - Query returned null (e.g., user not found)data - Query returned datafunction UserProfile({ userId }: { userId: Id<"users"> }) {
const user = useQuery(api.users.get, { userId });
// Loading state
if (user === undefined) {
return <div>Loading...</div>;
}
// Not found
if (user === null) {
return <div>User not found</div>;
}
// Data loaded
return <div>{user.name}</div>;
}
// WRONG - Will cause React hook errors!
const avatarUrl = profile?.avatarId
? useQuery(api.profiles.getAvatarUrl, { storageId: profile.avatarId })
: null;
// CORRECT - Use "skip" to conditionally skip the query
const avatarUrl = useQuery(
api.profiles.getAvatarUrl,
profile?.avatarId ? { storageId: profile.avatarId } : "skip"
);
function Dashboard() {
const user = useQuery(api.auth.loggedInUser);
// Skip queries until we have user data
const userPosts = useQuery(
api.posts.getByUser,
user ? { userId: user._id } : "skip"
);
const userSettings = useQuery(
api.settings.get,
user ? { userId: user._id } : "skip"
);
if (user === undefined) {
return <Loading />;
}
if (user === null) {
return <LoginPrompt />;
}
return (
<div>
<PostList posts={userPosts || []} />
<Settings settings={userSettings} />
</div>
);
}
function CreatePost() {
const createPost = useMutation(api.posts.create);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
try {
await createPost({ title, content });
setTitle("");
setContent("");
} catch (error) {
console.error("Failed to create post:", error);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
disabled={isSubmitting}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Content"
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting || !title || !content}>
{isSubmitting ? "Creating..." : "Create Post"}
</button>
</form>
);
}
import { useAction } from "convex/react";
import { api } from "../convex/_generated/api";
function AIChat() {
const generateResponse = useAction(api.ai.generateResponse);
const [prompt, setPrompt] = useState("");
const [response, setResponse] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
const result = await generateResponse({ prompt });
setResponse(result);
} catch (error) {
console.error("AI generation failed:", error);
} finally {
setIsLoading(false);
}
}
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ask AI..."
disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !prompt}>
{isLoading ? "Thinking..." : "Ask"}
</button>
</form>
{response && <p>{response}</p>}
</div>
);
}
When writing a UI component and you want to use a Convex function, you MUST import the api object:
import { api } from "../convex/_generated/api";
You can use the api object to call any public Convex function.
Always make sure:
convex/ directoryapi object for public functionsimport { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function InfiniteMessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.list,
{ 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 { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TodoList() {
const todos = useQuery(api.todos.list) || [];
const toggleTodo = useMutation(api.todos.toggle).withOptimisticUpdate(
(localStore, args) => {
const currentTodos = localStore.getQuery(api.todos.list);
if (currentTodos !== undefined) {
const updatedTodos = currentTodos.map((todo) =>
todo._id === args.id
? { ...todo, completed: !todo.completed }
: todo
);
localStore.setQuery(api.todos.list, {}, updatedTodos);
}
}
);
return (
<ul>
{todos.map((todo) => (
<li key={todo._id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo({ id: todo._id })}
/>
{todo.title}
</li>
))}
</ul>
);
}
function PostForm() {
const createPost = useMutation(api.posts.create);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(data: FormData) {
setError(null);
try {
await createPost({
title: data.get("title") as string,
content: data.get("content") as string,
});
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("An unexpected error occurred");
}
}
}
return (
<form action={handleSubmit}>
{error && <div className="error">{error}</div>}
{/* form fields */}
</form>
);
}
function DataComponent() {
const data = useQuery(api.data.get);
// Pattern 1: Simple loading check
if (data === undefined) {
return <Skeleton />;
}
// Pattern 2: With null check
if (data === null) {
return <NotFound />;
}
return <DataView data={data} />;
}
function ImageUploader() {
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveFile = useMutation(api.files.save);
const [uploading, setUploading] = useState(false);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Step 1: Get upload URL
const uploadUrl = await generateUploadUrl();
// Step 2: Upload file
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Upload failed");
}
const { storageId } = await result.json();
// Step 3: Save reference to database
await saveFile({ storageId, fileName: file.name });
} catch (error) {
console.error("Upload error:", error);
} finally {
setUploading(false);
}
}
return (
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
/>
);
}
function ImageGallery() {
const images = useQuery(api.images.list) || [];
return (
<div className="grid grid-cols-3 gap-4">
{images.map((image) => (
<ImageWithUrl key={image._id} storageId={image.storageId} />
))}
</div>
);
}
function ImageWithUrl({ storageId }: { storageId: Id<"_storage"> }) {
const url = useQuery(api.files.getUrl, { storageId });
if (url === undefined) {
return <div className="animate-pulse bg-gray-200 h-48" />;
}
if (url === null) {
return <div>Image not found</div>;
}
return <img src={url} alt="" className="w-full h-48 object-cover" />;
}
// main.tsx or _app.tsx
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
function App() {
return (
<ConvexAuthProvider client={convex}>
<YourApp />
</ConvexAuthProvider>
);
}
// WRONG
if (isLoggedIn) {
const data = useQuery(api.data.get);
}
// CORRECT
const data = useQuery(api.data.get, isLoggedIn ? {} : "skip");
function DataDisplay() {
const data = useQuery(api.data.get);
// Always handle: undefined (loading), null (not found), and data
if (data === undefined) return <Loading />;
if (data === null) return <NotFound />;
return <Content data={data} />;
}
import { Id } from "../convex/_generated/dataModel";
interface Props {
userId: Id<"users">; // Use Id<> type, not string
}
// Instead of passing data through many components,
// query it where needed
function DeepNestedComponent({ itemId }: { itemId: Id<"items"> }) {
// Query directly in the component that needs it
const item = useQuery(api.items.get, { id: itemId });
// ...
}
If you want to use a UI element, you MUST create it. DO NOT use external libraries like Shadcn/UI unless explicitly asked.
Always use canvas for image compression, not sharp.