ADR-04: TanStack Query Selection
| Date | Author | Repos |
|---|---|---|
| 2024-12-09 | @KubrickCode | web |
Context
The Data Fetching Challenge
The web platform requires client-side data fetching for:
- Polling-Based Status Tracking: Analysis jobs run asynchronously (queued → analyzing → completed/failed). The frontend must poll for status updates until completion.
- Cursor-Based Pagination: Dashboard lists use infinite scroll with cursor-based pagination from the Go backend.
- Mutation with Cache Sync: Actions like bookmarks and reanalysis must update cached data automatically.
- REST API Optimization: All data comes from REST endpoints defined in
openapi.yaml; no GraphQL.
Existing Architecture Constraints
- Next.js 16 + React 19: App Router with Server Components; data fetching hooks run in Client Components
- BFF Pattern: Next.js as thin presentation layer; Go backend handles all business logic
- OpenAPI Type Generation: TypeScript types generated from
openapi.yamlviaopenapi-typescript - No Global State Library: No Redux, Zustand, or similar global state management
Candidates Evaluated
- TanStack Query v5: Feature-rich data fetching library with polling, infinite queries, mutations
- SWR: Vercel's lightweight data fetching library
- RTK Query: Redux Toolkit's data fetching solution
- Apollo Client: GraphQL-focused but adaptable for REST
Decision
Adopt TanStack Query v5 as the primary data fetching library for its polling capabilities, infinite query support, and mature mutation handling.
Core principles:
- Query Key Factories: Centralized query key definitions per feature domain
- Conditional Polling: Use
refetchIntervalfunction for status-dependent polling - Cache Invalidation: Use
invalidateQueriesafter mutations for automatic data sync - Type Safety: Leverage OpenAPI-generated types in query functions
Options Considered
Option A: TanStack Query v5 (Selected)
How It Works:
QueryClientwith customized defaults (staleTime, error handlers)useQueryfor data fetching with automatic cachinguseInfiniteQueryfor cursor-based paginationuseMutationwithonSuccesscache invalidationrefetchIntervalwith function support for conditional polling
Pros:
- Polling Excellence:
refetchIntervalsupports functions for conditional polling with backoff - Infinite Queries: Native
useInfiniteQuerywithgetNextPageParamfor cursor pagination - Garbage Collection: Automatic cleanup of unused queries (default 5 minutes)
- DevTools: Official DevTools package for debugging cache states
- React 19 Support: Uses
useSyncExternalStore, fully compatible - Market Dominance: 60-70% market share, extensive documentation, community support
Cons:
- Larger bundle than SWR (~11-13 KB vs ~4.2 KB gzipped)
- Learning curve for advanced patterns
- HydrationBoundary boilerplate for SSR prefetching
Option B: SWR
How It Works:
useSWRfor data fetching with stale-while-revalidate strategyuseSWRInfinitefor paginationuseSWRMutationfor mutations (added in v2.0)
Evaluation:
- Missing Garbage Collection: No automatic cleanup of unused queries; memory leaks with dynamic queries
- Weaker Infinite Queries:
useSWRInfiniteless intuitive than TanStack'suseInfiniteQuery - No Official DevTools: Community-built alternatives only
- No staleTime Equivalent: Less control over when data is considered fresh
- Rejected: Insufficient for polling complexity and pagination requirements
Option C: RTK Query
How It Works:
- API slice definition with endpoints
- Generated hooks (
useGetXQuery,useLazyGetXQuery) - Tag-based cache invalidation
Evaluation:
- Redux Dependency: Requires Redux Toolkit adoption
- Infinite Queries Are New: Added February 2025, less battle-tested
- Overhead: Heavier setup for non-Redux applications
- Limited Next.js App Router Docs: Less documented for App Router patterns
- Rejected: Unnecessary Redux adoption for current architecture
Option D: Apollo Client
How It Works:
- GraphQL-first design with normalized cache
apollo-link-restadapter for REST APIs- Polling via
pollIntervaloption
Evaluation:
- REST Is Second-Class: Requires
apollo-link-restadapter - Bundle Size: ~30 KB gzipped, 3x larger than TanStack Query
- Normalized Cache Overhead: Complexity not needed for REST APIs
- GraphQL Concepts: Fragments, links, resolvers are GraphQL-specific
- Rejected: Significant overhead for REST-only application
Implementation Details
QueryClient Configuration
typescript
// lib/query/client.ts
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60, // 1 minute
},
},
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (isUnauthorizedError(error) && isAuthQuery(mutation.options.mutationKey)) {
handleUnauthorizedError(queryClient);
}
},
}),
queryCache: new QueryCache({
onError: (error, query) => {
if (isUnauthorizedError(error) && isAuthQuery(query.queryKey)) {
handleUnauthorizedError(queryClient);
}
},
}),
});Polling with Exponential Backoff
typescript
// features/analysis/hooks/use-analysis.ts
const INITIAL_INTERVAL_MS = 1000;
const MAX_INTERVAL_MS = 5000;
const BACKOFF_MULTIPLIER = 1.5;
const query = useQuery({
queryKey: analysisKeys.detail(owner, repo),
queryFn: () => fetchAnalysis(owner, repo),
refetchInterval: (query) => {
const response = query.state.data;
if (response && isTerminalStatus(response)) {
return false; // Stop polling
}
const interval = intervalRef.current;
intervalRef.current = Math.min(interval * BACKOFF_MULTIPLIER, MAX_INTERVAL_MS);
return interval;
},
});Cursor-Based Infinite Query
typescript
// features/dashboard/hooks/use-paginated-repositories.ts
export const usePaginatedRepositories = (options: PaginatedRepositoriesOptions) => {
const query = useInfiniteQuery({
queryKey: paginatedRepositoriesKeys.list({ limit, sortBy, sortOrder, view }),
queryFn: ({ pageParam }) =>
fetchPaginatedRepositories({
cursor: pageParam,
limit,
sortBy,
sortOrder,
view,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined),
staleTime: 30 * 1000,
});
const data = query.data?.pages.flatMap((page) => page.data) ?? [];
return { data, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage };
};Mutation with Cache Invalidation
typescript
// features/dashboard/hooks/use-bookmark-mutation.ts
export const useAddBookmark = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ owner, repo }) => addBookmark(owner, repo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paginatedRepositoriesKeys.all });
toast.success("Bookmark added");
},
onError: (error) => toast.error("Failed to add bookmark", { description: error.message }),
});
return { addBookmark: mutation.mutate, isPending: mutation.isPending };
};Query Key Factory Pattern
typescript
// Centralized query key definitions per feature
export const analysisKeys = {
all: ["analysis"] as const,
detail: (owner: string, repo: string) => [...analysisKeys.all, owner, repo] as const,
};
export const paginatedRepositoriesKeys = {
all: ["paginatedRepositories"] as const,
list: (options: PaginatedRepositoriesOptions) =>
[...paginatedRepositoriesKeys.all, "list", options] as const,
};Consequences
Positive
Polling Flexibility:
- Conditional polling with function-based
refetchInterval - Exponential backoff prevents server overload
- Automatic cleanup when polling stops
Pagination UX:
- Native infinite query support with cursor handling
- Lagged query data for smooth transitions
- Intersection Observer integration for auto-load
Developer Experience:
- Query key factories enable precise cache invalidation
- DevTools for debugging cache states in development
- Type-safe integration with OpenAPI-generated types
Memory Management:
- Automatic garbage collection of unused queries
- Configurable
gcTime(formerlycacheTime) prevents memory leaks - No manual cleanup required for dynamic queries
Negative
Bundle Size:
- ~11-13 KB gzipped vs SWR's ~4.2 KB
- Mitigation: Acceptable for dashboard application; DevTools are dev-only
SSR Complexity:
- Requires
QueryClientProviderwrapper in Client Component HydrationBoundaryneeded for SSR prefetching- Mitigation: BFF pattern minimizes SSR data requirements
Learning Curve:
- Advanced patterns (staleTime, gcTime, structural sharing) require study
- Mitigation: Established patterns in codebase; internal documentation
Usage Patterns Established
| Pattern | Implementation | File |
|---|---|---|
| Polling | refetchInterval with function | use-analysis.ts |
| Infinite Query | useInfiniteQuery + getNextPageParam | use-paginated-repositories.ts |
| Mutation | useMutation + invalidateQueries | use-bookmark-mutation.ts |
| Data Fetching | useQuery + query key factory | use-my-repositories.ts |
