Queries
Fetch and subscribe to real-time data with TanStack Query.
In this guide, we'll explore cRPC queries. You'll learn to fetch data with TanStack Query, subscribe to real-time updates, handle conditional queries, and migrate from vanilla Convex hooks.
Overview
cRPC queries provide a tRPC-like interface for fetching data with TanStack Query:
| Feature | Benefit |
|---|---|
| Real-time by default | WebSocket subscriptions update automatically |
| TanStack Query API | isPending, isError, refetch, devtools |
| Auth-aware | Skips queries when not authenticated |
| Type-safe keys | queryKey() and queryFilter() for cache control |
Let's see how it works.
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
function UserProfile({ id }: { id: string }) {
const crpc = useCRPC();
const { data, isPending } = useQuery(
crpc.user.get.queryOptions({ id })
);
if (isPending) return <div>Loading...</div>;
return <div>{data?.name}</div>;
}queryOptions
The queryOptions method creates options for TanStack Query's useQuery hook. Here's the basic usage:
const crpc = useCRPC();
// Basic usage
const { data } = useQuery(crpc.user.list.queryOptions({}));
// With arguments
const { data } = useQuery(crpc.user.get.queryOptions({ id: userId }));
// With TanStack Query options
const { data } = useQuery(
crpc.session.list.queryOptions(
{ userId },
{
enabled: !!userId,
placeholderData: [],
}
)
);Signature
crpc.path.to.query.queryOptions(
args, // Function arguments (or {} for no args)
options? // TanStack Query options + cRPC options
)Options
cRPC extends TanStack Query options with:
| Option | Type | Description |
|---|---|---|
skipUnauth | boolean | Skip query when not authenticated |
subscribe | boolean | Enable real-time updates (default: true) |
Plus all standard TanStack Query options: enabled, placeholderData, select, gcTime, etc.
With select
To transform query data with type inference, spread the query options and add select:
const { data } = useSuspenseQuery({
...crpc.http.health.queryOptions(),
select: (data) => data.status,
});
// data: string (not { status: string })TanStack Query infers the return type from the select function. Note that select cannot be passed directly to queryOptions().
Real-time Updates
By default, cRPC queries subscribe to Convex's WebSocket connection and automatically update when data changes on the server. This is the key difference from traditional REST APIs:
// This query receives real-time updates automatically
const { data } = useQuery(crpc.messages.list.queryOptions({ chatId }));
// When any client creates a message, all subscribers see it instantly
const { mutate } = useMutation(crpc.messages.create.mutationOptions());Disabling Subscriptions
For data that doesn't need real-time updates, disable subscriptions to reduce WebSocket traffic:
// One-time fetch, no subscription
const { data } = useQuery(
crpc.analytics.getReport.queryOptions(
{ period: 'monthly' },
{ subscribe: false }
)
);
// Manually refresh with invalidateQueries
const queryClient = useQueryClient();
queryClient.invalidateQueries(crpc.analytics.getReport.queryFilter());Note: With subscribe: false, the query behaves like a standard TanStack Query fetch. Use invalidateQueries to manually refresh the data when needed.
Conditional Queries
Sometimes you need to conditionally run queries. Let's explore the options.
With enabled
Use enabled to conditionally run queries:
const { data: user } = useQuery(crpc.user.get.queryOptions({ id: userId }));
// Only fetch settings after user is loaded
const { data: settings } = useQuery(
crpc.user.getSettings.queryOptions(
{ userId: user?._id },
{ enabled: !!user }
)
);With skipToken
For type-safe conditional queries, pass skipToken directly to queryOptions:
import { skipToken } from '@tanstack/react-query';
const { data } = useQuery(
crpc.user.get.queryOptions(userId ? { id: userId } : skipToken)
);Auth-aware Queries
cRPC provides two ways to handle authentication in queries.
skipUnauth
For queries that require authentication but shouldn't error when logged out:
// Returns undefined instead of throwing when not authenticated
const { data } = useQuery(
crpc.user.getCurrentUser.queryOptions(
{},
{ skipUnauth: true }
)
);This is useful for:
- Optional personalization on public pages
- Prefetching user data that may or may not exist
- Avoiding auth errors during SSR/hydration
Auth metadata
Procedures with .meta({ auth: 'required' }) have two key behaviors:
- During auth loading: Query is disabled (waits for auth handshake to complete)
- After auth settles: Query is skipped if user isn't authenticated
This prevents "unauthenticated" errors on first page load - the query won't run until we know the user's auth state.
// Server-side (convex/functions/user.ts)
export const getSettings = c
.query()
.meta({ auth: 'required' }) // Won't run if not authenticated
.handler(async ({ ctx }) => {
return ctx.db.get(ctx.user._id);
});
// Client-side - automatically skips when logged out
const { data } = useQuery(crpc.user.getSettings.queryOptions({}));// Server-side (convex/functions/user.ts)
export const getSettings = c
.query()
.meta({ auth: 'required' }) // Won't run if not authenticated
.handler(async ({ ctx }) => {
return ctx.table('user').get(ctx.user._id);
});
// Client-side - automatically skips when logged out
const { data } = useQuery(crpc.user.getSettings.queryOptions({}));How Auth Loading Works:
When your app loads, auth state is initially unknown (isLoading: true). How queries behave depends on their metadata:
| Procedure | Auth Loading | Logged Out |
|---|---|---|
publicQuery | Runs immediately | Runs |
optionalAuthQuery (auth: 'optional') | Waits | Runs |
authQuery (auth: 'required') | Waits | Skips |
Note: These procedure builders (authQuery, etc.) already include the correct .meta() settings. Just use authQuery - no need to add .meta({ auth: 'required' }) yourself.
When to use each:
| Use Case | Solution |
|---|---|
Query needs ctx.user | Use authQuery (has .meta({ auth: 'required' })) |
| Public page with optional user data | Use optionalAuthQuery + check ctx.user |
| Fully public data | Use publicQuery |
| Auth query that shouldn't error when logged out | Add skipUnauth: true client-side |
Loading States
Use placeholderData for skeleton UIs:
const { data, isPlaceholderData } = useQuery(
crpc.user.list.queryOptions(
{},
{
placeholderData: {
users: [
{ _id: '0' as Id<'user'>, name: 'Loading...' },
{ _id: '2' as Id<'user'>, name: 'Loading...' },
],
},
}
)
);
return (
<div>
{data?.users.map((user) => (
<WithSkeleton key={user._id} isLoading={isPlaceholderData}>
<UserCard user={user} />
</WithSkeleton>
))}
</div>
);Warning: Use static, predictable mock data in placeholderData. Random values cause hydration errors in SSR.
Query Keys
Get type-safe query keys for cache manipulation:
const crpc = useCRPC();
const queryClient = useQueryClient();
// Get query key
const queryKey = crpc.user.list.queryKey({});
// => ['convexQuery', 'user:list', {}]
// Read/write cache
const data = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, newData);Tip: With real-time subscriptions (default), invalidateQueries is rarely needed since data updates automatically via WebSocket. Use it with subscribe: false queries for manual cache control.
Query Filters
For advanced cache operations:
// Create a filter with additional options
const filter = crpc.user.list.queryFilter(
{},
{ predicate: (query) => query.state.dataUpdatedAt > Date.now() - 60000 }
);
// Use with invalidateQueries, cancelQueries, etc.
queryClient.invalidateQueries(filter);Actions as Queries
Convex actions (which can call external APIs) can be used as one-shot queries. They don't support real-time subscriptions:
// Actions are automatically detected and don't subscribe
const { data } = useQuery(crpc.ai.analyze.queryOptions({ documentId }));Imperative Calls
For queries in event handlers, effects, or callbacks, use useCRPCClient:
import { useCRPCClient } from '@/lib/convex/crpc';
const client = useCRPCClient();
const user = await client.user.get.query({ id });
await client.user.update.mutate({ id, name: 'test' });For cache-aware fetches in render context, use queryClient.fetchQuery:
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
const user = await queryClient.fetchQuery(crpc.user.get.queryOptions({ id }));staticQueryOptions
For prefetching in event handlers, use staticQueryOptions. Unlike queryOptions, it doesn't use hooks internally, so you can call it anywhere:
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
// Prefetch on hover - works in event handlers
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.user.get.staticQueryOptions({ id }));
};
// Fetch on click
const handleClick = async () => {
const user = await queryClient.fetchQuery(
crpc.user.get.staticQueryOptions({ id })
);
};Note: staticQueryOptions doesn't include reactive auth state. Auth is handled at execution time by the queryFn - if the user isn't authenticated for a protected query, it will throw an UNAUTHORIZED error.
Comparison
| Method | Context | Caching | Use Case |
|---|---|---|---|
client.*.query() | Anywhere | None | Direct calls without cache |
crpc.*.queryOptions() | Render only | Uses cache | Components (uses hooks) |
crpc.*.staticQueryOptions() | Anywhere | Uses cache | Prefetching, event handlers |
When to use which:
- Use
queryOptionsin components for reactive auth handling - Use
staticQueryOptionsfor prefetching in event handlers - Use
client.*.query()when you don't need TanStack Query caching
Migrate from Convex
If you're coming from vanilla Convex, here's what changes.
What stays the same
- Real-time updates via WebSocket
- Automatic retry and reconnection
- Database consistency guarantees
What's new
Before (vanilla Convex):
import { useQuery } from 'convex/react';
import { api } from '@convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
const data = useQuery(api.user.list, {});
type User = FunctionReturnType<typeof api.user.get>;After (cRPC):
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
import type { ApiOutputs } from '@convex/types';
const crpc = useCRPC();
const { data } = useQuery(crpc.user.list.queryOptions({}));
type User = ApiOutputs['user']['get'];Key differences:
| Feature | Description |
|---|---|
| TanStack Query API | isPending, isError, refetch, etc. |
| Query key management | queryKey() and queryFilter() |
| DevTools integration | React Query DevTools |
| One-time fetches | subscribe: false option |
| Imperative calls | useCRPCClient() for event handlers |
Imperative queries (event handlers, effects):
// Before // After
const convex = useConvex(); const client = useCRPCClient();
await convex.query(api.user.get, {}); await client.user.get.query({});Troubleshooting
"Unauthenticated" error on first page load
Symptom: Query throws UNAUTHORIZED error when page first loads, but works after refresh or navigation.
Cause: Query runs before auth handshake completes. Server receives request without token.
Fix: Ensure your server procedure uses .meta({ auth: 'required' }).