The Convex React library provides hooks and components for building reactive applications with real-time data synchronization.
Installation
Setup
Creating the client
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
Providing the client
Wrap your app with ConvexProvider:
import { ConvexProvider } from "convex/react";
function App() {
return (
<ConvexProvider client={convex}>
<YourApp />
</ConvexProvider>
);
}
Hooks
useQuery
Load reactive query data that automatically updates:
function useQuery<Query extends FunctionReference<"query">>(
query: Query,
args: Query["_args"] | "skip"
): Query["_returnType"] | undefined
Basic usage:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TaskList() {
const tasks = useQuery(api.tasks.list, { completed: false });
if (tasks === undefined) return <div>Loading...</div>;
return tasks.map((task) => (
<div key={task._id}>{task.text}</div>
));
}
Conditional queries:
Pass "skip" to conditionally disable a query:
function MaybeProfile({ userId }: { userId?: Id<"users"> }) {
const profile = useQuery(
api.users.get,
userId ? { userId } : "skip"
);
// ...
}
useMutation
Get a function to execute mutations:
function useMutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation
): ReactMutation<Mutation>
Basic usage:
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function CreateTask() {
const createTask = useMutation(api.tasks.create);
const handleClick = async () => {
await createTask({ text: "New task" });
};
return <button onClick={handleClick}>Add Task</button>;
}
Optimistic updates:
const deleteTask = useMutation(api.tasks.delete)
.withOptimisticUpdate((localStore, args) => {
const tasks = localStore.getQuery(api.tasks.list, {});
if (tasks !== undefined) {
localStore.setQuery(
api.tasks.list,
{},
tasks.filter((task) => task._id !== args.taskId)
);
}
});
useAction
Get a function to execute actions:
function useAction<Action extends FunctionReference<"action">>(
action: Action
): ReactAction<Action>
Basic usage:
import { useAction } from "convex/react";
import { api } from "../convex/_generated/api";
function GenerateSummary() {
const generate = useAction(api.ai.generateSummary);
const handleClick = async () => {
try {
const summary = await generate({ text: "Some long text..." });
console.log(summary);
} catch (error) {
console.error("Action failed:", error);
}
};
return <button onClick={handleClick}>Generate</button>;
}
Calling actions directly from clients is often an anti-pattern. Consider having the client call a mutation that records the user’s intent, then schedules the action via ctx.scheduler.runAfter.
usePaginatedQuery
Load paginated data for infinite scroll UIs:
function usePaginatedQuery<Query extends PaginatedQueryReference>(
query: Query,
args: PaginatedQueryArgs<Query> | "skip",
options: { initialNumItems: number }
): UsePaginatedQueryResult<Item>
Usage:
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function MessageList() {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.list,
{ channel: "#general" },
{ initialNumItems: 20 }
);
return (
<div>
{results.map((msg) => <div key={msg._id}>{msg.text}</div>)}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(10)}>Load More</button>
)}
{status === "LoadingMore" && <div>Loading...</div>}
</div>
);
}
Return value:
type UsePaginatedQueryResult<Item> = {
results: Item[];
loadMore: (numItems: number) => void;
} & (
| { status: "LoadingFirstPage"; isLoading: true }
| { status: "CanLoadMore"; isLoading: false }
| { status: "LoadingMore"; isLoading: true }
| { status: "Exhausted"; isLoading: false }
);
useConvex
Access the underlying ConvexReactClient:
function useConvex(): ConvexReactClient
Usage:
import { useConvex } from "convex/react";
import { api } from "../convex/_generated/api";
function MyComponent() {
const convex = useConvex();
const handleFetch = async () => {
// One-time query fetch
const result = await convex.query(api.tasks.list, {});
};
return <button onClick={handleFetch}>Fetch</button>;
}
useConvexConnectionState
Monitor the WebSocket connection state:
function useConvexConnectionState(): ConnectionState
Usage:
import { useConvexConnectionState } from "convex/react";
function ConnectionStatus() {
const state = useConvexConnectionState();
return (
<div>
{state.isConnected ? "Connected" : "Disconnected"}
</div>
);
}
Authentication components
Authenticated
Renders children only when authenticated:
import { Authenticated } from "convex/react";
function App() {
return (
<Authenticated>
<Dashboard />
</Authenticated>
);
}
Unauthenticated
Renders children only when not authenticated:
import { Unauthenticated } from "convex/react";
function App() {
return (
<Unauthenticated>
<LoginPage />
</Unauthenticated>
);
}
AuthLoading
Renders children while authentication is loading:
import { AuthLoading } from "convex/react";
function App() {
return (
<AuthLoading>
<LoadingSpinner />
</AuthLoading>
);
}
Combined example:
import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";
function App() {
return (
<>
<AuthLoading>
<LoadingSpinner />
</AuthLoading>
<Authenticated>
<Dashboard />
</Authenticated>
<Unauthenticated>
<LoginPage />
</Unauthenticated>
</>
);
}
Authentication
Set up authentication with an async token fetcher:
convex.setAuth(
async () => {
// Return JWT token or null if unavailable
return await fetchAuthToken();
},
(isAuthenticated) => {
console.log("Auth changed:", isAuthenticated);
}
);
Clear authentication:
Helper functions for optimistic updates in paginated queries:
insertAtTop
import { insertAtTop } from "convex/react";
const createTask = useMutation(api.tasks.create)
.withOptimisticUpdate((localStore, args) => {
insertAtTop({
paginatedQuery: api.tasks.list,
argsToMatch: { listId: args.listId },
localQueryStore: localStore,
item: {
_id: crypto.randomUUID() as Id<"tasks">,
title: args.title,
completed: false
}
});
});
insertAtPosition
import { insertAtPosition } from "convex/react";
const createTask = useMutation(api.tasks.create)
.withOptimisticUpdate((localStore, args) => {
insertAtPosition({
paginatedQuery: api.tasks.listByPriority,
argsToMatch: { listId: args.listId },
sortOrder: "asc",
sortKeyFromItem: (item) => [item.priority, item._creationTime],
localQueryStore: localStore,
item: {
_id: crypto.randomUUID() as Id<"tasks">,
_creationTime: Date.now(),
title: args.title,
priority: args.priority,
completed: false
}
});
});
Best practices
- Always use hooks inside React components or custom hooks
- Handle the
undefined loading state from useQuery
- Use
"skip" for conditional queries instead of conditional hooks
- Wrap mutation/action calls in try-catch blocks
- Use optimistic updates for better UX
- The functions returned by
useMutation and useAction are stable across renders
- Don’t pass React events directly to mutations - wrap them in handlers
Type safety
All hooks are fully type-safe with generated API types:
import { api } from "../convex/_generated/api";
// Arguments and return types are automatically inferred
const tasks = useQuery(api.tasks.list, {
completed: false // Type-checked
});
// tasks has type Task[] | undefined
const createTask = useMutation(api.tasks.create);
// createTask expects { text: string } and returns Promise<Id<"tasks">>