kitcn

Queries

Fetch and subscribe to real-time data with TanStack Query.

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.

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: [],
    }
  )
);

See API Reference for the full signature and options.

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

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.orm.query.user.findFirst({ where: { id: 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.

See API Reference for a method comparison table.

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

API Reference

queryOptions

crpc.path.to.query.queryOptions(
  args,      // Function arguments (or {} for no args)
  options?   // TanStack Query options + cRPC 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.

Method Comparison

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

On this page