BETTER-CONVEX

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:

FeatureBenefit
Real-time by defaultWebSocket subscriptions update automatically
TanStack Query APIisPending, isError, refetch, devtools
Auth-awareSkips queries when not authenticated
Type-safe keysqueryKey() 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:

OptionTypeDescription
skipUnauthbooleanSkip query when not authenticated
subscribebooleanEnable 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:

  1. During auth loading: Query is disabled (waits for auth handshake to complete)
  2. 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:

ProcedureAuth LoadingLogged Out
publicQueryRuns immediatelyRuns
optionalAuthQuery (auth: 'optional')WaitsRuns
authQuery (auth: 'required')WaitsSkips

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 CaseSolution
Query needs ctx.userUse authQuery (has .meta({ auth: 'required' }))
Public page with optional user dataUse optionalAuthQuery + check ctx.user
Fully public dataUse publicQuery
Auth query that shouldn't error when logged outAdd 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

MethodContextCachingUse Case
client.*.query()AnywhereNoneDirect calls without cache
crpc.*.queryOptions()Render onlyUses cacheComponents (uses hooks)
crpc.*.staticQueryOptions()AnywhereUses cachePrefetching, event handlers

When to use which:

  • Use queryOptions in components for reactive auth handling
  • Use staticQueryOptions for 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:

FeatureDescription
TanStack Query APIisPending, isError, refetch, etc.
Query key managementqueryKey() and queryFilter()
DevTools integrationReact Query DevTools
One-time fetchessubscribe: false option
Imperative callsuseCRPCClient() 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' }).

Next Steps

On this page