TanStack Query (formerly React Query) is the standard library for managing server state in React applications. It handles data fetching, caching, synchronization, background refetching, and stale-while-revalidate patterns—replacing the manual useEffect + useState + loading/error state management that plagues most React data-fetching code. When building dashboards that display live metrics, TanStack Query’s automatic refetching and cache invalidation keep the UI current without custom polling logic.
A common challenge in dashboard applications is that backend APIs don’t always provide the exact data shape the UI needs. An API might return raw memory statistics but not the per-category breakdowns the dashboard displays, or it might provide hourly data when the chart needs daily aggregations. The fallback pattern addresses this by computing derived data client-side when the API lacks the specific breakdown, using TanStack Query’s select option to transform raw responses into the shape components expect.
The AI memory dashboard implements this pattern with a Next.js App Router dashboard that streams memory statistics via React Query, recomputes aggregations client-side when APIs lack breakdowns, and visualizes trends with shadcn/ui components and Recharts. This architecture demonstrates how to build responsive dashboards that gracefully handle API limitations while maintaining a smooth user experience with optimistic updates and streaming data.
useQuery takes a query key (for cache identity) and a query function (the actual fetch). It returns { data, isLoading, isError, error, isFetching } and automatically manages the entire lifecycle: initial fetch, caching, background refetch when the component remounts or the window regains focus, and retry on failure. The query key is an array that acts as a dependency—when any element changes, the query refetches.
The select option transforms the raw API response before it reaches the component. This runs on every cache read (not just on fetch), so it can compute derived values, filter data, or reshape responses. For dashboard fallbacks, select is where client-side aggregation happens: if the API returns per-minute data but the chart needs hourly buckets, select groups and averages the raw data points.
TanStack Query separates “stale” from “expired”. Data becomes stale after staleTime (default: 0, meaning immediately stale). Stale data is still shown to the user while a background refetch runs, avoiding loading spinners on revisits. Data is garbage-collected from the cache after gcTime (default: 5 minutes of inactivity). For dashboards, tuning staleTime to match the data’s natural update frequency (e.g., 30 seconds for memory stats) prevents unnecessary API calls while keeping the UI reasonably current.
Dashboard panels typically need data from multiple endpoints. Multiple useQuery calls in the same component execute in parallel automatically. For dependent queries (where one query needs the result of another), use the enabled option: enabled: !!userId delays the query until the dependency is resolved. This patterns avoids waterfall fetching while respecting data dependencies between panels.
For live dashboards, refetchInterval automatically re-executes the query at a fixed interval. Combined with refetchIntervalInBackground: true, the dashboard stays current even when the tab is not focused. This replaces custom setInterval + cleanup patterns and integrates with TanStack Query’s cache, so intermediate results are properly cached and shared across components that use the same query key.
The QueryClient is configured at the application root with defaults appropriate for a dashboard: moderate stale time, automatic refetching on window focus, and retry logic for transient failures.
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30s before data is considered stale
gcTime: 5 * 60 * 1000, // 5min before unused cache is GC'd
refetchOnWindowFocus: true, // Refresh when user returns to tab
retry: 2, // Retry failed requests twice
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
When the API returns raw memory samples but the dashboard needs category breakdowns, the select option computes aggregations client-side. This fallback runs transparently—if the API later adds a breakdown endpoint, swap the query function without changing the component.
// hooks/useMemoryStats.ts
import { useQuery } from "@tanstack/react-query";
interface RawMemorySample {
timestamp: string;
category: string;
bytes_used: number;
bytes_total: number;
}
interface CategoryBreakdown {
category: string;
used: number;
total: number;
percentage: number;
}
async function fetchMemoryStats(): Promise<RawMemorySample[]> {
const res = await fetch("/api/memory/stats");
if (!res.ok) throw new Error("Failed to fetch memory stats");
return res.json();
}
// Aggregation fallback: group by category, compute latest values
function aggregateByCategory(samples: RawMemorySample[]): CategoryBreakdown[] {
const latest = new Map<string, RawMemorySample>();
// Keep only the most recent sample per category
for (const sample of samples) {
const existing = latest.get(sample.category);
if (!existing || sample.timestamp > existing.timestamp) {
latest.set(sample.category, sample);
}
}
return Array.from(latest.values()).map((s) => ({
category: s.category,
used: s.bytes_used,
total: s.bytes_total,
percentage: (s.bytes_used / s.bytes_total) * 100,
}));
}
export function useMemoryBreakdown() {
return useQuery({
queryKey: ["memory", "stats"],
queryFn: fetchMemoryStats,
select: aggregateByCategory, // Transform raw data to breakdown
refetchInterval: 10 * 1000, // Poll every 10 seconds
});
}
// Same raw data, different transformation for the time-series chart
export function useMemoryTimeSeries(category: string) {
return useQuery({
queryKey: ["memory", "stats"],
queryFn: fetchMemoryStats,
select: (samples) =>
samples
.filter((s) => s.category === category)
.map((s) => ({
time: new Date(s.timestamp).toLocaleTimeString(),
used: s.bytes_used / (1024 * 1024), // Convert to MB
})),
});
}
Each dashboard panel is a self-contained component that uses a query hook. TanStack Query handles loading states, errors, and cache coordination—multiple panels sharing the same query key share a single network request.
// components/MemoryBreakdownCard.tsx
"use client";
import { useMemoryBreakdown } from "@/hooks/useMemoryStats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
const COLORS = ["#6c63ff", "#00d4aa", "#ff6b8a", "#ffd93d", "#4ecdc4"];
export function MemoryBreakdownCard() {
const { data, isLoading, isError, error, isFetching } = useMemoryBreakdown();
if (isLoading) return <CardSkeleton />;
if (isError) return <CardError message={error.message} />;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Memory Breakdown
{isFetching && <RefetchIndicator />}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={data}
dataKey="used"
nameKey="category"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
The time-series hook feeds an area chart that shows memory usage over time. The select transformation filters to the selected category and converts units, keeping the component simple.
// components/MemoryTrendChart.tsx
"use client";
import { useMemoryTimeSeries } from "@/hooks/useMemoryStats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
ResponsiveContainer, Tooltip,
} from "recharts";
export function MemoryTrendChart({ category }: { category: string }) {
const { data, isLoading } = useMemoryTimeSeries(category);
if (isLoading) return <ChartSkeleton />;
return (
<Card>
<CardHeader>
<CardTitle>{category} Memory Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2d40" />
<XAxis dataKey="time" stroke="#6b6785" fontSize={12} />
<YAxis stroke="#6b6785" fontSize={12} unit=" MB" />
<Tooltip
contentStyle={{ background: "#1c1f2e", border: "1px solid #2a2d40" }}
/>
<Area
type="monotone"
dataKey="used"
stroke="#6c63ff"
fill="rgba(108, 99, 255, 0.2)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
The AI memory dashboard uses this pattern to build a memory monitoring dashboard for an AI memory system. The backend API exposes raw memory usage samples (timestamp, category, bytes used/total), but the frontend needs multiple derived views: a pie chart breakdown by category, a time-series trend per category, summary statistics (total used, peak usage, growth rate), and alerts when categories exceed thresholds. Rather than adding five specialized API endpoints, the dashboard fetches raw samples once and computes each view client-side using select transformations.
This single-fetch-multiple-views pattern works because TanStack Query caches the raw response and runs select on cache reads. Five components using the same query key with different select functions trigger only one network request but each component receives its own transformed view. When the 10-second polling interval fires, a single refetch updates all five views simultaneously.
The dashboard is built with Next.js App Router, using shadcn/ui for the card layout and Recharts for the visualizations. Server components render the page shell with suspense boundaries, and client components handle the interactive data display. This separation means the initial page load is fast (server-rendered layout), and data population happens client-side with skeleton placeholders that resolve as queries complete.
Centralize query key construction to prevent key mismatches between queries and invalidations. A factory function ensures consistent keys and makes invalidation patterns discoverable.
// lib/queryKeys.ts
export const memoryKeys = {
all: ["memory"] as const,
stats: () => [...memoryKeys.all, "stats"] as const,
trend: (category: string) => [...memoryKeys.all, "trend", category] as const,
alerts: () => [...memoryKeys.all, "alerts"] as const,
};
// Usage in hooks
useQuery({ queryKey: memoryKeys.stats(), queryFn: fetchMemoryStats });
// Invalidate all memory queries after a mutation
queryClient.invalidateQueries({ queryKey: memoryKeys.all });
Use placeholderData to show stale or default data while refetching fails, and useErrorBoundary to escalate persistent failures to React error boundaries.
export function useMemoryBreakdown() {
return useQuery({
queryKey: memoryKeys.stats(),
queryFn: fetchMemoryStats,
select: aggregateByCategory,
refetchInterval: 10_000,
placeholderData: (previousData) => previousData, // Keep last good data
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
});
}
Prefetch data for dashboard tabs the user hasn’t clicked yet. When they switch, data is already in cache.
// Prefetch on hover or mount
export function usePrefetchCategoryTrend(category: string) {
const queryClient = useQueryClient();
useEffect(() => {
queryClient.prefetchQuery({
queryKey: memoryKeys.trend(category),
queryFn: () => fetchMemoryStats(),
staleTime: 30_000,
});
}, [category, queryClient]);
}
new QueryClient() directly in a component body (not in useState) creates a new client every render, destroying the cache. Always use useState(() => new QueryClient()) or create the client outside the component for singleton behavior.select function runs on every cache read, including re-renders from unrelated state changes. If the transformation is expensive (sorting thousands of items, complex aggregation), memoize the select function with useCallback or extract it as a stable reference outside the component.refetchInterval: 1000 (1 second) on multiple panels that share data creates unnecessary load. Instead, use a single query with a reasonable interval (10-30 seconds) and share it across panels via the same query key. TanStack Query deduplicates requests for the same key automatically.queryFn captures component state via closure, it may use stale values on refetch. Pass dynamic values through the query key instead: queryKey: ["memory", category] ensures refetch uses the current category. The query function receives the key as a parameter via queryFn: ({ queryKey }) => fetch(queryKey[1]).useQuery should be wrapped in <Suspense> boundaries with fallback skeletons. Without these, the entire page blocks on data loading. Place suspense boundaries around each independent dashboard panel, not around the entire page.useQuery calls with the same key but different queryFn implementations cause unpredictable behavior—the last one to fetch wins. Only the select option should differ; the queryFn must be identical for a given key.