Skip to Content
Part 1: FoundationsCh 4: React Query

Every piece of data you see in Astrelo — the alert feed, the deal predictions, the prospect rankings — was fetched from the server, cached in memory, and kept in sync as you interact with the app. The library that orchestrates all of this is TanStack React Query (formerly React Query).

The Problem React Query Solves

Without React Query, every component that needs server data would have to:

  1. Store its own loading state (const [loading, setLoading] = useState(true))
  2. Store its own data state (const [data, setData] = useState(null))
  3. Store its own error state (const [error, setError] = useState(null))
  4. Call fetch() in a useEffect
  5. Handle race conditions (what if the component unmounts before the fetch completes?)
  6. Decide when to refetch (after a mutation? on window focus? on an interval?)
  7. Avoid duplicate requests (if two components need the same data, don’t fetch twice)

That’s 7 concerns for every single data fetch. Multiply by 68 API endpoints and dozens of components, and you have a maintenance nightmare.

React Query reduces all of this to one hook:

const { data, isLoading, error } = useQuery({ queryKey: ['alerts'], queryFn: () => fetch('/api/alerts').then(r => r.json()), });

One line gives you: automatic loading states, error handling, caching, deduplication, background refetching, and garbage collection. The rest of this chapter explains how.

Setting Up the QueryClient

Every React Query application starts with a QueryClient — a singleton that manages the cache and default configuration:

// src/pages/_app.tsx, lines 89-120 const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, // Data is "fresh" for 30 seconds gcTime: 5 * 60 * 1000, // Keep unused data for 5 minutes retry: 1, // Retry failed requests once refetchOnWindowFocus: false, // Don't refetch when user switches tabs }, mutations: { onError: (error) => { console.error('[Mutation Error]', error); }, }, }, }) );

Let’s break down each option:

  • staleTime: 30_000 — After data is fetched, it’s considered “fresh” for 30 seconds. During this window, any component that requests the same data gets the cached version instantly — no network request. After 30 seconds, the data is “stale” and will be refetched in the background on the next access.

  • gcTime: 300_000 — When no component is using a piece of cached data, React Query keeps it in memory for 5 minutes before garbage collecting it. If the user navigates away and comes back within 5 minutes, the old data appears instantly while a fresh fetch happens in the background.

  • retry: 1 — If a fetch fails (network error, 500 response), React Query retries once before giving up. This catches transient errors without hammering a broken server.

  • refetchOnWindowFocus: false — By default, React Query refetches all stale queries when the user switches back to the browser tab. We disable this globally because it would fire dozens of requests simultaneously, which is wasteful for a dashboard that’s already polling the most important data.

The QueryClient is created inside useState(() => ...) — a pattern that ensures one instance per React tree. If you used a plain variable at module scope, server-side rendering could share state between different users’ requests.

The client is then provided to all components:

// src/pages/_app.tsx, lines 127-149 return ( <QueryClientProvider client={queryClient}> <AuthProvider> <Component {...pageProps} /> </AuthProvider> {process.env.NODE_ENV === 'development' && ( <ReactQueryDevtools initialIsOpen={false} /> )} </QueryClientProvider> );

In development, ReactQueryDevtools adds a floating panel that shows every cached query, its status, and when it was last fetched. This is invaluable for debugging “why isn’t my data updating?” issues.

Query Keys: The Cache Address System

Every piece of cached data needs a unique address. React Query uses query keys — arrays that identify what data a query represents. Astrelo centralizes all keys in a single factory:

// src/shared/queryKeys.ts, lines 186-194 alerts: { all: ['alerts'] as const, feed: (params?: { limit?: number; unviewed?: boolean }) => [...queryKeys.alerts.all, 'feed', params] as const, unviewedCount: () => [...queryKeys.alerts.all, 'unviewed-count'] as const, settings: () => [...queryKeys.alerts.all, 'settings'] as const, aiContent: (alertId: string) => [...queryKeys.alerts.all, 'ai-content', alertId] as const, }

This is a hierarchical factory pattern (popularized by TkDodo, a React Query maintainer). The hierarchy enables granular cache invalidation:

['alerts'] ← Invalidates ALL alert queries ['alerts', 'feed', { limit: 20 }] ← Invalidates just the feed ['alerts', 'unviewed-count'] ← Invalidates just the badge count ['alerts', 'ai-content', 'abc-123'] ← Invalidates one alert's AI content

When you call queryClient.invalidateQueries({ queryKey: ['alerts'] }), React Query invalidates every query whose key starts with ['alerts']. This is prefix matching — invalidating ['alerts'] catches all four keys above. Invalidating ['alerts', 'feed'] only catches the feed.

Why centralize keys? Without a factory, keys would be scattered across hooks:

// BAD — keys defined wherever they're used: useQuery({ queryKey: ['alerts', 'feed'] }); // In AlertFeed.tsx useQuery({ queryKey: ['alerts', 'count'] }); // In NotificationBell.tsx useQuery({ queryKey: ['alerts', 'feed'] }); // In AlertDrawer.tsx — same key? Different? Who knows?

With the factory, every key is defined once, imported everywhere, and refactoring is safe:

// GOOD — keys from central factory: useQuery({ queryKey: queryKeys.alerts.feed({ limit }) }); useQuery({ queryKey: queryKeys.alerts.unviewedCount() });

Fetching Data: The useQuery Pattern

Here’s the most common pattern in the codebase — a hook that fetches data from an API endpoint:

// src/features/alerts/hooks/useAlerts.ts, lines 11-26 export function useAlerts(limit: number = 20) { const { token } = useAuth(); return useQuery({ queryKey: queryKeys.alerts.feed({ limit }), queryFn: async (): Promise<AlertFeedResponse> => { const res = await fetch(`/api/alerts?limit=${limit}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to fetch alerts'); return res.json(); }, enabled: !!token, staleTime: 15_000, }); }

Let’s examine each piece:

useAuth() — Getting the JWT

const { token } = useAuth();

The AuthContext (Chapter 2) provides the JWT token to every component. The token is attached to every API request as Authorization: Bearer <token>. Without it, the server returns 401.

queryKey — The Cache Address

queryKey: queryKeys.alerts.feed({ limit }), // Evaluates to: ['alerts', 'feed', { limit: 20 }]

If two components call useAlerts(20), they share the same cache entry. If one calls useAlerts(20) and another calls useAlerts(50), they’re separate cache entries because the limit parameter differs.

queryFn — The Fetch Function

queryFn: async (): Promise<AlertFeedResponse> => { const res = await fetch(`/api/alerts?limit=${limit}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to fetch alerts'); return res.json(); },

This is a plain fetch call. React Query doesn’t care how you get the data — it just needs a function that returns a promise. If the promise resolves, the data is cached. If it rejects (throws), the error is captured and exposed via the error property.

The !res.ok check is important: fetch doesn’t throw on HTTP errors (4xx, 5xx). A 500 response is still a “successful” fetch from the browser’s perspective. You must check the status and throw manually.

enabled — Conditional Fetching

enabled: !!token,

!!token converts the token to a boolean. If the user isn’t logged in yet (token is null), enabled is false and the query never fires. This prevents a 401 error on app load before the auth context has initialized.

This pattern appears on almost every query in the codebase. Without it, you’d see a flash of “Unauthorized” errors every time the app starts.

staleTime — Freshness Override

staleTime: 15_000,

This overrides the global 30-second default. Alerts are more time-sensitive — we want fresher data. After 15 seconds, the cached alerts are “stale” and will be refetched on the next access.

A More Complex Query: Deal Predictions

Not all queries are simple fetches. Here’s one that transforms data after fetching:

// src/features/overview/components/CommandCenter/hooks/useDealPredictions.ts, lines 15-39 export function useDealPredictions(dealIds: string[], authToken?: string | null) { const { data, isLoading } = useQuery({ queryKey: queryKeys.predictions.byDeals(dealIds), queryFn: async () => { if (dealIds.length === 0) return { predictions: [] }; const res = await fetch(`/api/predictions?dealIds=${dealIds.join(',')}`, { headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, }); if (!res.ok) throw new Error('Failed to fetch predictions'); return res.json() as Promise<{ predictions: DealPrediction[] }>; }, enabled: dealIds.length > 0, staleTime: 5 * 60 * 1000, }); // Transform: array → Map for O(1) lookups by deal ID const predictions = data?.predictions || []; const predictionsByDealId = new Map<string, DealPrediction[]>(); for (const p of predictions) { const existing = predictionsByDealId.get(p.dealId) || []; existing.push(p); predictionsByDealId.set(p.dealId, existing); } return { predictions, predictionsByDealId, isLoading }; }

Key differences from the simple pattern:

  1. enabled: dealIds.length > 0 — Don’t fetch if there are no deals to predict. This avoids an unnecessary API call.

  2. staleTime: 5 * 60 * 1000 (5 minutes) — Predictions are expensive to compute and don’t change often. We keep them fresh longer.

  3. Post-fetch transformation — The API returns an array, but the component needs a Map<dealId, predictions[]> for O(1) lookups. The transformation happens outside the queryFn so the raw data is cached (the Map is rebuilt on every render, which is fine — it’s a cheap operation).

Mutations: Changing Data on the Server

Queries fetch data. Mutations change it. Every form submission, button click, or state change that sends data to the server is a mutation.

Simple Mutation with Cache Invalidation

// src/features/alerts/hooks/useAlerts.ts, lines 28-44 export function useMarkViewed() { const { token } = useAuth(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (alertId: string) => { const res = await fetch(`/api/alerts/${alertId}/view`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to mark viewed'); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.alerts.all }); }, }); }

When the mutation succeeds, onSuccess fires and invalidates all alert queries. This causes React Query to refetch the alert feed and the unviewed count — both update to reflect that the alert was viewed.

useQueryClient() gives you access to the cache. You need it for invalidation, optimistic updates, and direct cache manipulation.

The Mutation Lifecycle

User clicks "Mark as Read" → mutationFn fires (POST to server) → Server processes request → onSuccess: invalidate alert cache → React Query refetches alert feed in background → UI updates with fresh data

Optimistic Updates: Making the UI Feel Instant

Network requests take 50-200ms. For actions where the outcome is predictable (toggling a checkbox, approving an action), we can update the UI before the server responds. If the server fails, we roll back.

This is the most sophisticated React Query pattern in the codebase:

// src/features/alerts/hooks/useAutonomousActions.ts, lines 49-96 export function useApproveAction() { const { token } = useAuth(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (actionId: string) => { const res = await fetch('/api/autonomous-actions', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ actionId, action: 'approve' }), }); if (!res.ok) throw new Error('Failed to approve action'); return res.json(); }, // BEFORE the mutation fires: onMutate: async (actionId: string) => { // Step 1: Cancel any in-flight queries (prevent race conditions) await queryClient.cancelQueries({ queryKey: queryKeys.autonomousActions.all }); // Step 2: Snapshot the current data (for rollback) const previousData = queryClient.getQueryData( queryKeys.autonomousActions.list({ status: 'pending' }) ); // Step 3: Optimistically remove the action from the list queryClient.setQueryData( queryKeys.autonomousActions.list({ status: 'pending' }), (old: ListResponse | undefined) => { if (!old) return old; return { ...old, actions: old.actions.filter(a => a.id !== actionId), }; } ); // Step 4: Return the snapshot as context return { previousData }; }, // IF the mutation fails: onError: (_err, _actionId, context) => { // Rollback: restore the snapshot if (context?.previousData) { queryClient.setQueryData( queryKeys.autonomousActions.list({ status: 'pending' }), context.previousData ); } }, // AFTER the mutation (success OR failure): onSettled: () => { // Belt-and-suspenders: refetch to ensure cache matches server queryClient.invalidateQueries({ queryKey: queryKeys.autonomousActions.all }); queryClient.invalidateQueries({ queryKey: queryKeys.alerts.all }); }, }); }

This is a four-step dance:

1. onMutate → Save snapshot, update UI optimistically 2. mutationFn → Send request to server 3. onError → If server fails, restore snapshot (rollback) 4. onSettled → Regardless of outcome, refetch to sync with server

Why cancel queries first? Imagine this timeline:

T=0ms: User clicks "Approve" T=5ms: onMutate removes action from UI T=10ms: A background refetch completes with the OLD data T=11ms: UI shows the action again (it's in the refetched data!) T=200ms: Server confirms approval

By canceling in-flight queries at T=0, we prevent the refetch at T=10 from overwriting our optimistic update.

Why onSettled in addition to onMutate? The optimistic update is an educated guess. onSettled runs after the mutation completes (whether it succeeded or failed) and triggers a full refetch. This is the “belt-and-suspenders” approach — even if our optimistic update was slightly wrong (e.g., the server added extra data), the refetch corrects it.

The Auth Token Flow

Every API call needs the JWT token. There are two patterns in the codebase:

Pattern 1: Direct from useAuth()

Most hooks use this pattern:

const { token } = useAuth(); // In queryFn: headers: { Authorization: `Bearer ${token}` }

The token comes from React context (Chapter 2). The enabled: !!token guard ensures we don’t fetch before the token is available.

Pattern 2: The fetcher Utility

For cleaner code, some hooks use a centralized fetcher:

// src/shared/api/fetcher.ts, lines 57-80 export async function fetcher<T>( url: string, authToken?: string | null, options: FetcherOptions = {} ): Promise<T> { const token = authToken !== undefined ? authToken : getAuthToken(); const headers: Record<string, string> = { 'Content-Type': 'application/json', ...options.headers, }; if (token && !options.skipAuth) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorMessage = `Request failed with status ${response.status}`; try { const errorData = await response.json(); errorMessage = errorData?.error || errorData?.message || errorMessage; } catch { /* Response wasn't JSON */ } throw new ApiError(errorMessage, response.status); } if (response.status === 204) return null as T; return response.json(); }

The fetcher handles:

  • Auto-attaching the auth token
  • Parsing error messages from JSON responses
  • Handling 204 No Content responses
  • Setting the Content-Type header

Usage is much cleaner:

// Without fetcher: const res = await fetch('/api/overview/todos', { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, }); if (!res.ok) throw new Error('Failed'); return res.json(); // With fetcher: return fetcher<TodosResponse>('/api/overview/todos', token);

Cache Invalidation Strategies

Knowing when to invalidate the cache is one of the hardest problems in frontend development. Astrelo uses three strategies:

Strategy 1: Broad Invalidation

After a major operation (like CRM sync), invalidate everything related:

// src/pages/_app.tsx, lines 35-40 const handleSyncComplete = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['overview'] }); queryClient.invalidateQueries({ queryKey: ['companies'] }); queryClient.invalidateQueries({ queryKey: ['deals'] }); queryClient.invalidateQueries({ queryKey: ['ranking'] }); }, [queryClient]);

A CRM sync changes companies, deals, and rankings — invalidate all of them at once.

Strategy 2: Targeted Invalidation

After a focused mutation, only invalidate what changed:

// After marking an alert as viewed: queryClient.invalidateQueries({ queryKey: queryKeys.alerts.all });

This refetches the alert feed and unviewed count, but doesn’t touch companies, deals, or anything else.

Strategy 3: Cross-Domain Invalidation

Some mutations affect multiple domains:

// After approving an autonomous action: onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.autonomousActions.all }); queryClient.invalidateQueries({ queryKey: queryKeys.alerts.all }); },

Approving an action affects both the actions list and the alerts feed (the alert’s acted_on_at timestamp gets set).

Key Takeaways

  1. React Query is a server state manager. It handles fetching, caching, synchronization, and garbage collection. You don’t need useState + useEffect for server data.

  2. Query keys are cache addresses. The hierarchical factory pattern (queryKeys.alerts.feed()) enables precise cache invalidation — from “everything” down to “one specific alert’s AI content.”

  3. enabled prevents premature fetches. Always guard queries with enabled: !!token to avoid 401 errors during app initialization.

  4. Optimistic updates make the UI feel instant. The onMutate → mutationFn → onError → onSettled lifecycle handles the snapshot, update, rollback, and sync.

  5. staleTime controls freshness. Alerts use 15 seconds (time-sensitive). Predictions use 5 minutes (expensive, stable). The global default is 30 seconds.

  6. Cache invalidation is an art. Broad invalidation after major operations, targeted after focused mutations, cross-domain when mutations ripple across features.

Next chapter: we’ll look at the API route pattern — how the server handles the requests that React Query sends.

Last updated on