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
- Caching: Automatic background refetching and cache invalidation
- Loading States: Built-in loading, error, success states
- Optimistic Updates: Update UI before server confirms
- Pagination: Built-in pagination and infinite scroll
- 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