Claude-skill-registry convex-react
Convex React client - hooks, real-time updates, optimistic updates, pagination, and UI patterns. Use when working with useQuery, useMutation, useAction, usePaginatedQuery, convex/react, ConvexProvider, ConvexReactClient, optimistic updates, skip, real-time, or loading states in React.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convex-react" ~/.claude/skills/majiayu000-claude-skill-registry-convex-react && rm -rf "$T"
manifest:
skills/data/convex-react/SKILL.mdsource content
Convex React Client Guide
Complete React client guidelines for Convex, including hooks, real-time updates, optimistic updates, and best practices for building reactive UIs.
Basic React Integration
Complete Example
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> ); }
useQuery Hook
Real-time Updates
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.
Return Values
- Query is loadingundefined
- Query returned null (e.g., user not found)null
- Query returned datadata
function 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>; }
Conditional Queries with "skip"
CRITICAL: Never Use Hooks Conditionally
// 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" );
More Examples
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> ); }
useMutation Hook
Basic Usage
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> ); }
useAction Hook
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> ); }
Importing the API Object
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:
- The functions you are calling are defined in the
directoryconvex/ - Use the
object for public functionsapi - You are using the correct arguments for convex functions
- If arguments are not optional, make sure they are not null
Pagination with usePaginatedQuery
import { 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> ); }
Optimistic Updates
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> ); }
Error Handling
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> ); }
Loading States Pattern
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} />; }
File Upload Pattern
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} /> ); }
Image Display with Storage URLs
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" />; }
Provider Setup
// 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> ); }
Best Practices
1. Never Call Hooks Conditionally
// WRONG if (isLoggedIn) { const data = useQuery(api.data.get); } // CORRECT const data = useQuery(api.data.get, isLoggedIn ? {} : "skip");
2. Handle All States
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} />; }
3. Use TypeScript Properly
import { Id } from "../convex/_generated/dataModel"; interface Props { userId: Id<"users">; // Use Id<> type, not string }
4. Avoid Prop Drilling with Queries
// 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 }); // ... }
5. Do NOT Use External UI Libraries Unless Specified
If you want to use a UI element, you MUST create it. DO NOT use external libraries like Shadcn/UI unless explicitly asked.
6. Do NOT Use sharp for Image Compression
Always use
canvas for image compression, not sharp.