React Dashboard with TanStack Query Fallbacks

Frontend Engineering intermediate Building with AI ~22 min read

Overview

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.

Core Concepts

useQuery for Declarative Data Fetching

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.

select for Client-Side Data Transformation

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.

Stale-While-Revalidate Caching

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.

Parallel and Dependent Queries

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.

Streaming with refetchInterval

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.

How It Works

Basic Dashboard Query Setup

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>
  );
}

Memory Stats with Client-Side Aggregation

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
        })),
  });
}

Dashboard Panel Components

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>
  );
}

Trend Visualization with Recharts

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>
  );
}

Practical Applications

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.

Implementation Patterns

Query Key Factory

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 });

Error Boundaries with Fallback Data

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),
  });
}

Prefetching for Instant Tab Switches

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]);
}

Common Pitfalls

Further Reading