Skip to main content

ADR-0020: TanStack Query for Data Fetching

Status

Accepted - 2025-01-26


Context

TVL Platform needs to fetch server data (bookings, properties, payments) in React components with caching, refetching, and optimistic updates.


Decision

TanStack Query (React Query) for server state management and data fetching.

Rationale

  1. Caching: Automatic background refetching and cache invalidation
  2. Loading States: Built-in loading, error, success states
  3. Optimistic Updates: Update UI before server confirms
  4. Pagination: Built-in pagination and infinite scroll
  5. DevTools: Inspect queries, mutations, cache

Alternatives Considered

Alternative 1: SWR

Rejected - Less feature-rich, smaller ecosystem

Alternative 2: Apollo Client

Rejected - GraphQL-specific, we use REST (ADR-0023)

Alternative 3: Native fetch + useEffect

Rejected - No caching, must implement loading states manually


Installation

pnpm add @tanstack/react-query

Setup

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
cacheTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false
}
}
});

export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

Usage Examples

1. Fetch Data (useQuery)

import { useQuery } from '@tanstack/react-query';

function BookingsList() {
const { data, isLoading, error } = useQuery({
queryKey: ['bookings'],
queryFn: async () => {
const res = await fetch('/api/bookings');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
});

if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;

return (
<div>
{data.bookings.map(booking => (
<BookingCard key={booking.id} booking={booking} />
))}
</div>
);
}

2. Mutations (useMutation)

import { useMutation, useQueryClient } from '@tanstack/react-query';

function ConfirmBookingButton({ bookingId }: { bookingId: string }) {
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/bookings/${id}/confirm`, {
method: 'POST'
});
return res.json();
},
onSuccess: () => {
// Invalidate and refetch bookings
queryClient.invalidateQueries({ queryKey: ['bookings'] });
}
});

return (
<button
onClick={() => mutation.mutate(bookingId)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Confirming...' : 'Confirm'}
</button>
);
}

3. Optimistic Updates

const mutation = useMutation({
mutationFn: confirmBooking,
onMutate: async (bookingId) => {
// Cancel refetch
await queryClient.cancelQueries({ queryKey: ['bookings'] });

// Snapshot previous value
const previous = queryClient.getQueryData(['bookings']);

// Optimistically update
queryClient.setQueryData(['bookings'], (old) =>
old.map(b => b.id === bookingId ? { ...b, status: 'confirmed' } : b)
);

return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['bookings'], context.previous);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['bookings'] });
}
});

4. Pagination

function BookingsList() {
const [page, setPage] = useState(1);

const { data, isLoading } = useQuery({
queryKey: ['bookings', { page }],
queryFn: async () => {
const res = await fetch(`/api/bookings?page=${page}`);
return res.json();
},
keepPreviousData: true // Keep showing old data while fetching new
});

return (
<div>
{data?.bookings.map(...)}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(p => p + 1)} disabled={!data?.hasMore}>
Next
</button>
</div>
);
}

5. Dependent Queries

// Fetch property details first, then bookings for that property
const { data: property } = useQuery({
queryKey: ['properties', propertyId],
queryFn: () => fetchProperty(propertyId)
});

const { data: bookings } = useQuery({
queryKey: ['bookings', { propertyId }],
queryFn: () => fetchBookings(propertyId),
enabled: !!property // Only run when property loaded
});

Query Keys Best Practices

// Good - hierarchical keys
['bookings'] // All bookings
['bookings', { status: 'pending' }] // Filtered bookings
['bookings', bookingId] // Single booking
['bookings', bookingId, 'payments'] // Nested resource

// Bad - flat keys
['pending-bookings']
['booking-123']

Cache Invalidation

// Invalidate all booking queries
queryClient.invalidateQueries({ queryKey: ['bookings'] });

// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['bookings', bookingId] });

// Set data manually (after mutation)
queryClient.setQueryData(['bookings', bookingId], newBooking);

// Remove from cache
queryClient.removeQueries({ queryKey: ['bookings', bookingId] });

Benefits

  • Automatic Caching: No manual cache management
  • Background Refetching: Keep data fresh automatically
  • Loading States: Built-in isLoading, error handling
  • Optimistic Updates: Fast UI updates
  • Pagination: Built-in support
  • DevTools: Debug queries and cache

References