Skip to content

Instantly share code, notes, and snippets.

@zbeyens
Created January 9, 2026 10:54
Show Gist options
  • Select an option

  • Save zbeyens/bbea784e01cbb4780c94a9f820cef861 to your computer and use it in GitHub Desktop.

Select an option

Save zbeyens/bbea784e01cbb4780c94a9f820cef861 to your computer and use it in GitHub Desktop.
Convex infinite query
/** biome-ignore-all lint/suspicious/noExplicitAny: Convex query/mutation type compatibility */
import type { PaginatedQueryItem, PaginatedQueryReference } from 'convex/react';
import type { FunctionReference } from 'convex/server';
import { getFunctionName } from 'convex/server';
import { useEffect, useMemo } from 'react';
import type { PartialDeep } from 'type-fest';
import { CRPCClientError, isCRPCClientError } from '../crpc/error';
import type { ConvexInfiniteQueryOptions } from '../crpc/types';
import { useAuthValue } from './auth-store';
import { useConvexInfiniteQuery as useInfiniteQueryInternal } from './use-infinite-query';
type PaginationStatus =
| 'CanLoadMore'
| 'Exhausted'
| 'LoadingFirstPage'
| 'LoadingMore';
/** Return type for useCRPCInfiniteQuery */
export type UseCRPCInfiniteQueryResult<T> = {
/** Flattened array of all loaded items */
data: T[];
/** First error encountered, if any */
error: Error | null;
/** Fetch the next page */
fetchNextPage: (numItems?: number) => void;
/** Whether the query has a next page */
hasNextPage: boolean;
/** Whether any page has an error */
isError: boolean;
/** Whether the query is fetching */
isFetching: boolean;
/** Whether fetching next page failed */
isFetchNextPageError: boolean;
/** Whether the query is fetching the next page */
isFetchingNextPage: boolean;
/** Whether the query is loading the first page */
isLoading: boolean;
/** Whether the data is placeholder data */
isPlaceholderData: boolean;
/** Whether we're refetching (have data but still fetching) */
isRefetching: boolean;
/** Whether the query has loaded successfully */
isSuccess: boolean;
/** Current pagination status */
status: PaginationStatus;
};
/** Options for useCRPCInfiniteQuery hook */
export type UseCRPCInfiniteQueryOptions<T> = {
/** Enable/disable query */
enabled?: boolean;
/** Placeholder data while loading */
placeholderData?: PartialDeep<T>[];
};
/**
* Infinite query hook using cRPC-style options.
* Accepts options from `crpc.posts.list.infiniteQueryOptions()`.
*
* @example
* ```tsx
* const crpc = useCRPC();
* const { data, fetchNextPage } = useCRPCInfiniteQuery(
* crpc.posts.list.infiniteQueryOptions({ userId }, { initialNumItems: 20 })
* );
* ```
*/
export function useCRPCInfiniteQuery<
T extends FunctionReference<'query'>,
TItem = T extends PaginatedQueryReference ? PaginatedQueryItem<T> : unknown,
>(
infiniteOptions: ConvexInfiniteQueryOptions<T>,
options?: UseCRPCInfiniteQueryOptions<TItem>
): UseCRPCInfiniteQueryResult<TItem> {
const onQueryUnauthorized = useAuthValue('onQueryUnauthorized');
const isAuthLoading = useAuthValue('isLoading');
const isAuthenticated = useAuthValue('isAuthenticated');
// Extract metadata from infiniteOptions
const { _infinite } = infiniteOptions;
const { query, args, initialNumItems, authType, skipUnauth, debug } =
_infinite;
// Default skipUnauth to false (throws CRPCClientError)
const skipUnauthFinal = skipUnauth ?? false;
// Auth required but user not authenticated (after auth loads)
// Only check auth if query is enabled (enabled !== false)
const isUnauthorized =
options?.enabled !== false &&
authType === 'required' &&
!isAuthLoading &&
!isAuthenticated;
// Determine if we should skip the query
// Only wait for auth loading on required queries (not optional/public)
const shouldSkip =
(authType === 'required' && isAuthLoading) ||
options?.enabled === false ||
(authType === 'required' && !isAuthenticated);
// Create error when unauthorized (unless skipUnauth)
// Both cases skip query, but skipUnauth returns empty instead of error
const authError = useMemo(() => {
if (isUnauthorized && !skipUnauthFinal) {
return new CRPCClientError({
code: 'UNAUTHORIZED',
functionName: getFunctionName(query),
});
}
return null;
}, [isUnauthorized, skipUnauthFinal, query]);
// Call callback in useEffect (not during render) to avoid setState-in-render
useEffect(() => {
if (isUnauthorized && !skipUnauthFinal) {
onQueryUnauthorized({ queryName: getFunctionName(query) });
}
}, [isUnauthorized, skipUnauthFinal, query, onQueryUnauthorized]);
const result = useInfiniteQueryInternal(query as any, args as any, {
initialNumItems,
debug,
// Internal hook handles prefetch detection and will bypass skip if data exists
enabled: !shouldSkip,
placeholderData: options?.placeholderData as any,
});
// Return auth error state when auth required and not skipUnauth
if (authError) {
return {
data: [] as TItem[],
error: authError,
fetchNextPage: () => {},
hasNextPage: false,
isError: true,
isFetching: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isRefetching: false,
isSuccess: false,
status: 'Exhausted' as const,
};
}
// Return skipped state when skipUnauth triggers (not placeholder data)
if (skipUnauthFinal && isUnauthorized) {
return {
data: [] as TItem[],
error: null,
fetchNextPage: () => {},
hasNextPage: false,
isError: false,
isFetching: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isRefetching: false,
isSuccess: false,
status: 'Exhausted' as const,
};
}
// Include auth loading in loading state for optional and required types
const authLoadingApplies = authType === 'optional' || authType === 'required';
// Check if we got an auth error
const isAuthError = isCRPCClientError(result.error);
return {
...result,
data: result.data as TItem[],
isLoading:
(authLoadingApplies && isAuthLoading) ||
(!isAuthError && result.isLoading),
};
}
/** biome-ignore-all lint/suspicious/noExplicitAny: Convex query/mutation type compatibility */
import { useQueries, useQueryClient } from '@tanstack/react-query';
import type {
PaginatedQueryArgs,
PaginatedQueryItem,
PaginatedQueryReference,
} from 'convex/react';
import { getFunctionName, type PaginationResult } from 'convex/server';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { PartialDeep } from 'type-fest';
import { CRPCClientError, isCRPCClientError } from '../crpc/error';
import { convexQuery } from '../crpc/query-options';
import { useAuthSkip } from '../internal/auth';
import { useAuthValue } from './auth-store';
import { useMeta } from './context';
/**
* Pagination state persisted in queryClient.
* Enables scroll restoration when navigating back to a paginated list.
*/
export type PaginationState = {
id: number;
nextPageKey: number;
pageKeys: number[];
queries: Record<
number,
{
args: Record<string, unknown> & {
paginationOpts: { cursor: string | null; id: number; numItems: number };
};
endCursor?: string | null;
}
>;
version: number;
};
// Query key prefix for pagination state storage
const PAGINATION_KEY_PREFIX = '__pagination__' as const;
// Pagination ID store - persists across mounts for cache reuse
// Key: query name + args, Value: pagination ID
const paginationIdStore = new Map<string, number>();
let paginationIdCounter = 0;
const getOrCreatePaginationId = (storeKey: string): number => {
const existing = paginationIdStore.get(storeKey);
if (existing !== undefined) {
return existing;
}
const newId = ++paginationIdCounter;
paginationIdStore.set(storeKey, newId);
return newId;
};
type PaginationStatus =
| 'CanLoadMore'
| 'Exhausted'
| 'LoadingFirstPage'
| 'LoadingMore';
type PageState = {
args: Record<string, unknown> & {
paginationOpts: { cursor: string | null; id: number; numItems: number };
};
endCursor?: string | null; // For page splitting - the cursor where this page ends
};
// Page splitting: when a page gets too large, Convex may return splitCursor
// - SplitRecommended: page is large, should split on next render
// - SplitRequired: page MUST be split (too large to return)
type PageResultWithSplit<T> = PaginationResult<T> & {
splitCursor?: string | null;
};
/**
* Infinite query for users using TanStack Query + convexQuery.
* Each page gets:
* - Convex WebSocket subscription (real-time reactivity)
* - TanStack Query retry on timeout errors
*/
const useInfiniteQuery = <Query extends PaginatedQueryReference>(
query: Query,
args: PaginatedQueryArgs<Query>,
options: {
initialNumItems: number;
debug?: boolean;
enabled?: boolean;
placeholderData?: PartialDeep<PaginatedQueryItem<Query>>[];
}
): {
/** Flattened array of all loaded items */
data: PaginatedQueryItem<Query>[];
/** First error encountered, if any */
error: Error | null;
/** Fetch the next page */
fetchNextPage: (numItems?: number) => void;
/** Whether the query has a next page */
hasNextPage: boolean;
/** Whether any page has an error */
isError: boolean;
/** Whether the query is fetching */
isFetching: boolean;
/** Whether fetching next page failed */
isFetchNextPageError: boolean;
/** Whether the query is fetching the next page */
isFetchingNextPage: boolean;
/** Whether the query is loading the first page */
isLoading: boolean;
/** Whether the data is placeholder data */
isPlaceholderData: boolean;
/** Whether we're refetching (have data but still fetching) */
isRefetching: boolean;
/** Whether the query has loaded successfully */
isSuccess: boolean;
/** Current pagination status */
status: PaginationStatus;
} => {
const isAuthLoading = useAuthValue('isLoading');
const meta = useMeta();
const queryClient = useQueryClient();
// Look up server-prefetched data using server-compatible queryKey
// Server key: ['convexQuery', funcName, { ...args, paginationOpts: { cursor: null, numItems } }]
const prefetchedFirstPage = useMemo(() => {
const serverQueryKey = [
'convexQuery',
getFunctionName(query),
{
...(args as Record<string, unknown>),
paginationOpts: {
cursor: null,
numItems: options.initialNumItems,
},
},
];
const data = queryClient.getQueryData(serverQueryKey);
if (options.debug && data) {
console.log('[useInfiniteQuery] Found prefetched data:', data);
}
return data ?? null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, JSON.stringify(args), options.initialNumItems, queryClient, options.debug]);
// Don't skip if we have prefetched data - use it for instant hydration
// Prefetched data bypasses both auth loading AND explicit disabled
const skip =
!prefetchedFirstPage && (isAuthLoading || options.enabled === false);
// Helper to get/set pagination state from queryClient with gcTime: Infinity
const getPaginationState = useCallback(
(key: string): PaginationState | undefined => {
const queryKey = [PAGINATION_KEY_PREFIX, key] as const;
const state = queryClient.getQueryData<PaginationState>(queryKey);
return state;
},
[queryClient]
);
const setPaginationState = useCallback(
(key: string, state: PaginationState) => {
const queryKey = [PAGINATION_KEY_PREFIX, key] as const;
queryClient.setQueryData<PaginationState>(queryKey, state);
},
[queryClient]
);
const argsObject = useMemo(
() => (skip ? {} : args) as Record<string, unknown>,
// eslint-disable-next-line react-hooks/exhaustive-deps
[skip, JSON.stringify(args)]
);
// Stable store key for pagination ID persistence across mounts
const storeKey = useMemo(
() => JSON.stringify({ query: getFunctionName(query), args: argsObject }),
[query, argsObject]
);
// Helper to create initial state
const createInitialState = useCallback((): PaginationState => {
const id = getOrCreatePaginationId(storeKey);
return {
id,
nextPageKey: 1,
pageKeys: skip ? [] : [0],
queries: skip
? {}
: {
0: {
args: {
...argsObject,
paginationOpts: {
cursor: null,
id,
numItems: options.initialNumItems,
},
},
},
},
version: 0,
};
}, [storeKey, skip, argsObject, options.initialNumItems]);
// Track previous args to detect changes (in effect, not during render)
const prevArgsRef = useRef<{ storeKey: string; skip: boolean } | null>(null);
// State: tracks pages with cursors (mirrors Convex's usePaginatedQuery)
// Check queryClient first for state persistence across navigations
const [state, setLocalState] = useState<PaginationState>(() => {
if (skip) {
return { id: 0, nextPageKey: 1, pageKeys: [], queries: {}, version: 0 };
}
// Try to restore from queryClient (enables scroll restoration)
const existingState = getPaginationState(storeKey);
if (existingState) {
return existingState;
}
const initial = createInitialState();
return initial;
});
// Sync state changes to queryClient for persistence across navigations
const setState = useCallback(
(
updater: PaginationState | ((prev: PaginationState) => PaginationState)
) => {
setLocalState((prev) => {
const newState =
typeof updater === 'function' ? updater(prev) : updater;
setPaginationState(storeKey, newState);
return newState;
});
},
[storeKey, setPaginationState]
);
// Handle initialization and args changes
// This effect initializes state when skip becomes false, or resets when args change
useEffect(() => {
const prev = prevArgsRef.current;
const isFirstRun = prev === null;
const argsChanged =
prev !== null && (prev.storeKey !== storeKey || prev.skip !== skip);
const skipBecameFalse = prev?.skip && !skip;
// Update ref for next render
prevArgsRef.current = { storeKey, skip };
// Skip state - don't initialize
if (skip) {
return;
}
// First run with skip=false: state was initialized in useState, sync to queryClient
if (isFirstRun) {
setPaginationState(storeKey, state);
return;
}
// Skip just became false (auth loaded): initialize state
if (skipBecameFalse) {
// Try to restore from queryClient first
const existingState = getPaginationState(storeKey);
if (existingState) {
setLocalState(existingState);
return;
}
// Create new initial state
const newState = createInitialState();
setLocalState(newState);
setPaginationState(storeKey, newState);
return;
}
// Args changed (different query/args): reset state
if (argsChanged) {
// Try to restore from queryClient first (for back navigation)
const existingState = getPaginationState(storeKey);
if (existingState) {
setLocalState(existingState);
return;
}
// Create new initial state
const newState = createInitialState();
setLocalState(newState);
setPaginationState(storeKey, newState);
}
}, [
skip,
storeKey,
state,
createInitialState,
getPaginationState,
setPaginationState,
]);
// Reset pagination on InvalidCursor error (data changed, cursors invalid)
const initialNumItems = options.initialNumItems;
const resetPagination = useCallback(() => {
// Invalidate old pagination ID by removing from store
paginationIdStore.delete(storeKey);
const newId = getOrCreatePaginationId(storeKey);
setState((prev) => ({
id: newId,
nextPageKey: 1,
pageKeys: [0],
queries: {
0: {
args: {
...argsObject,
paginationOpts: {
cursor: null,
id: newId,
numItems: initialNumItems,
},
},
},
},
version: prev.version + 1,
}));
}, [storeKey, argsObject, initialNumItems]);
// Build TanStack queries from state (each page = separate convexQuery)
// structuralSharing: false ensures Convex WebSocket updates trigger re-renders
const tanstackQueries = useMemo(
() =>
state.pageKeys.map((key, index) => ({
...convexQuery(
query,
(state.queries[key]?.args ?? 'skip') as any,
meta
),
enabled: !skip && !!state.queries[key],
structuralSharing: false,
// Use server-prefetched data for first page (hydration)
...(index === 0 && prefetchedFirstPage
? { initialData: prefetchedFirstPage }
: {}),
})),
[
query,
state.pageKeys,
state.queries,
skip,
meta,
prefetchedFirstPage,
]
);
// Use combine to aggregate all page states in one place
const combined = useQueries({
queries: tanstackQueries as any,
combine: (results) => {
// Aggregate pages with deduplication
const allItems: PaginatedQueryItem<Query>[] = [];
const seenIds = new Set<string>();
let lastPage: PageResultWithSplit<PaginatedQueryItem<Query>> | undefined;
let paginationStatus: PaginationStatus = 'LoadingFirstPage';
for (let i = 0; i < results.length; i++) {
const pageQuery = results[i];
if (pageQuery.isLoading || pageQuery.data === undefined) {
paginationStatus = i === 0 ? 'LoadingFirstPage' : 'LoadingMore';
break;
}
const page = pageQuery.data as PageResultWithSplit<
PaginatedQueryItem<Query>
>;
lastPage = page;
for (const item of page.page) {
const id =
(item as { _id?: string })._id || (item as { id?: string }).id;
if (id && seenIds.has(id)) continue;
if (id) seenIds.add(id);
allItems.push(item);
}
paginationStatus = page.isDone ? 'Exhausted' : 'CanLoadMore';
}
// Reuse TanStack Query states directly
const isPlaceholderData = results[0]?.isPlaceholderData ?? false;
const isFetching = results.some((r) => r.isFetching);
// Track dataUpdatedAt to force re-computation on Convex WebSocket updates
const dataUpdatedAt = results.map((r) => r.dataUpdatedAt ?? 0).join('|');
return {
// Aggregated data
data: allItems,
dataUpdatedAt,
lastPage,
status: paginationStatus,
// TanStack Query states - reuse directly
error: results.find((r) => r.isError)?.error ?? null,
isError: results.some((r) => r.isError),
isFetching,
isFetchNextPageError:
results.length > 1 && (results.at(-1)?.isError ?? false),
isLoading: results[0]?.isLoading ?? false,
isPending: results[0]?.isPending ?? true,
isPlaceholderData,
isRefetching: isFetching && allItems.length > 0 && !isPlaceholderData,
isSuccess: results.length > 0 && (results[0]?.isSuccess ?? false),
// Keep raw results for effects (InvalidCursor detection, page splitting)
_rawResults: results,
};
},
});
// Check for InvalidCursor errors and reset if needed
useEffect(() => {
for (const pageQuery of combined._rawResults) {
if (pageQuery.error) {
const errorMessage =
pageQuery.error instanceof Error ? pageQuery.error.message : '';
if (errorMessage.includes('InvalidCursor')) {
resetPagination();
return;
}
}
}
}, [combined._rawResults, resetPagination]);
// Handle page splitting - when a page returns splitCursor, we need to split it
useEffect(() => {
for (let i = 0; i < combined._rawResults.length; i++) {
const pageQuery = combined._rawResults[i];
if (pageQuery.data) {
const page = pageQuery.data as PageResultWithSplit<
PaginatedQueryItem<Query>
>;
const pageKey = state.pageKeys[i];
const pageState = state.queries[pageKey];
// Check if this page needs splitting and we haven't already split it
if (page.splitCursor && pageState && !pageState.endCursor) {
setState((prev) => {
const currentPageState = prev.queries[pageKey];
if (!currentPageState || currentPageState.endCursor) return prev;
const newKey = prev.nextPageKey;
const splitCursor = page.splitCursor!; // Checked above: page.splitCursor is truthy
const splitPageArgs = {
...argsObject,
paginationOpts: {
cursor: splitCursor,
id: prev.id,
numItems: currentPageState.args.paginationOpts.numItems,
},
};
// Insert new page after the split page
const pageKeyIndex = prev.pageKeys.indexOf(pageKey);
const newPageKeys = [...prev.pageKeys];
newPageKeys.splice(pageKeyIndex + 1, 0, newKey);
return {
...prev,
nextPageKey: newKey + 1,
pageKeys: newPageKeys,
queries: {
...prev.queries,
// Mark current page with its end cursor
[pageKey]: {
...currentPageState,
endCursor: splitCursor,
},
// Add the new split page
[newKey]: {
args: splitPageArgs,
},
} as Record<number, PageState>,
};
});
return; // Only handle one split per render
}
}
}
}, [combined._rawResults, state.pageKeys, state.queries, argsObject]);
// loadMore: add new page to state
const loadMore = useCallback(
(numItems: number) => {
if (
combined.status !== 'CanLoadMore' ||
!combined.lastPage?.continueCursor
)
return;
setState((prev) => {
const newKey = prev.nextPageKey;
return {
...prev,
nextPageKey: newKey + 1,
pageKeys: [...prev.pageKeys, newKey],
queries: {
...prev.queries,
[newKey]: {
args: {
...argsObject,
paginationOpts: {
cursor: combined.lastPage!.continueCursor,
id: prev.id,
numItems,
},
},
},
},
};
});
},
[combined.status, combined.lastPage?.continueCursor, argsObject]
);
// Apply placeholder data if provided and first page is pending (no data yet)
const showPlaceholder = !!options?.placeholderData && combined.isPending;
const data = showPlaceholder
? (options.placeholderData as PaginatedQueryItem<Query>[])
: combined.data;
return {
data,
error: combined.error instanceof Error ? combined.error : null,
fetchNextPage: (numItems?: number) =>
loadMore(numItems ?? options.initialNumItems),
hasNextPage: combined.status === 'CanLoadMore',
isError: combined.isError,
isFetching: combined.isFetching,
isFetchNextPageError: combined.isFetchNextPageError,
isFetchingNextPage: combined.status === 'LoadingMore',
isLoading: combined.isLoading,
isPlaceholderData: showPlaceholder,
isRefetching: combined.isRefetching,
isSuccess: combined.isSuccess,
status: combined.status,
};
};
/**
* Unified infinite query hook with auto-detected auth behavior.
* Auth type is determined from generated metadata based on the function's builder.
*/
export const useConvexInfiniteQuery = <Query extends PaginatedQueryReference>(
query: Query,
args: PaginatedQueryArgs<Query>,
options: {
initialNumItems: number;
debug?: boolean;
enabled?: boolean;
placeholderData?: PartialDeep<PaginatedQueryItem<Query>>[];
/** Skip query silently when unauthenticated (default: false, throws CRPCClientError) */
skipUnauth?: boolean;
}
) => {
const onQueryUnauthorized = useAuthValue('onQueryUnauthorized');
const { authType, isAuthenticated, isAuthLoading, shouldSkip } = useAuthSkip(
query,
{ enabled: options?.enabled, skipUnauth: options?.skipUnauth }
);
// Default skipUnauth to false (throws CRPCClientError)
const skipUnauth = options?.skipUnauth ?? false;
// Auth required but user not authenticated (after auth loads)
// Only check auth if query is enabled (enabled !== false)
const isUnauthorized =
options.enabled !== false &&
authType === 'required' &&
!isAuthLoading &&
!isAuthenticated;
// Create error when unauthorized (unless skipUnauth)
// Both cases skip query, but skipUnauth returns empty instead of error
const authError = useMemo(() => {
if (isUnauthorized && !skipUnauth) {
return new CRPCClientError({
code: 'UNAUTHORIZED',
functionName: getFunctionName(query),
});
}
return null;
}, [isUnauthorized, skipUnauth, query]);
// Call callback in useEffect (not during render) to avoid setState-in-render
useEffect(() => {
if (isUnauthorized && !skipUnauth) {
onQueryUnauthorized({ queryName: getFunctionName(query) });
}
}, [isUnauthorized, skipUnauth, query, onQueryUnauthorized]);
const result = useInfiniteQuery(query, args as any, {
...options,
// Internal hook handles prefetch detection and will bypass skip if data exists
enabled: !shouldSkip,
});
// Return auth error state when auth required and not skipUnauth
if (authError) {
return {
data: [] as PaginatedQueryItem<Query>[],
error: authError,
fetchNextPage: () => {},
hasNextPage: false,
isError: true,
isFetching: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isRefetching: false,
isSuccess: false,
status: 'Exhausted' as const,
};
}
// Return skipped state when skipUnauth triggers (not placeholder data)
if (skipUnauth && isUnauthorized) {
return {
data: [] as PaginatedQueryItem<Query>[],
error: null,
fetchNextPage: () => {},
hasNextPage: false,
isError: false,
isFetching: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
isRefetching: false,
isSuccess: false,
status: 'Exhausted' as const,
};
}
// Include auth loading in loading state for optional and required types
const authLoadingApplies = authType === 'optional' || authType === 'required';
// Check if we got an auth error
const isAuthError = isCRPCClientError(result.error);
return {
...result,
isLoading:
(authLoadingApplies && isAuthLoading) ||
(!isAuthError && result.isLoading),
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment