tva
← Insights

Building a Customizable Widget Dashboard with TanStack Query

Most product dashboards are built the same way: a top-level component fires off ten API calls, waits for all of them, then renders everything at once. In a demo, this looks fine. In production, it creates a class of problems that compound over time. A single slow endpoint blocks the entire view. A transient error on one widget breaks the whole screen. Real-time updates require coordinated refetching that eventually becomes impossible to maintain. Adding a new widget means reasoning about global state that was never designed to accommodate it.

TanStack Query (formerly React Query) solves this by treating data fetching as a first-class concern, not an afterthought bolted onto component state. Each query has its own cache key, lifecycle, and error state. In a widget dashboard, this translates directly: each widget owns its data and is responsible for its own loading, error, and success states. The rest of the application doesn't need to know or care.

Separating layout from data

Before writing any data fetching logic, the architecture requires a clear separation between two concerns: what a widget fetches and where it sits. These are independently mutable — a user can move a widget without changing what data it shows, and the backend can return new data without affecting the layout. Conflating them creates coordination problems later.

interface WidgetConfig {
  id: string;
  type: 'revenue' | 'orders' | 'users' | 'activity-feed';
  title: string;
  queryKey: string[];
  refetchInterval?: number;
}

interface WidgetLayout {
  i: string;   // matches WidgetConfig.id
  x: number;
  y: number;
  w: number;
  h: number;
}

The queryKey field on WidgetConfig is the bridge to TanStack Query's cache. Two widgets with the same queryKey will share a cached result — one network request, two consumers. Two widgets with different keys are fully independent. This distinction matters when building composite dashboards where multiple views display the same underlying data.

Per-widget data fetching

Each widget component calls useQuery with its own configuration. The staleTime tells TanStack Query how long to treat cached data as current before marking it stale. Setting this correctly per widget type prevents unnecessary refetching while keeping data reasonably fresh.

function RevenueWidget({ config }: { config: WidgetConfig }) {
  const { data, isLoading, isError } = useQuery({
    queryKey: config.queryKey,
    queryFn: () => fetchRevenue(),
    staleTime: 60_000,
    refetchInterval: config.refetchInterval,
  });

  if (isLoading) return <WidgetSkeleton />;
  if (isError) return <WidgetError />;

  return (
    <div className="widget-content">
      <p className="text-3xl font-bold">{formatCurrency(data.total)}</p>
      <p className="text-sm text-muted">vs last period: {data.changePercent}%</p>
    </div>
  );
}

The skeleton and error states are intentionally widget-local. There is no global loading spinner, no application-level error handler catching widget failures. Each widget presents its own state to the user — which means a revenue widget showing a loading skeleton alongside an orders widget displaying live data is the expected, correct behavior, not a sign of something broken.

Error boundaries per widget

The isError state from useQuery handles fetch errors, but render errors — thrown during component execution — require React's error boundary mechanism. Wrapping each widget in its own boundary means a widget that throws during render doesn't crash the entire dashboard. The user continues working with their other widgets while the broken one shows a recoverable error state.

class WidgetErrorBoundary extends React.Component<
  { children: React.ReactNode; widgetId: string },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="widget-error">
          <p>This widget encountered an error.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Retry
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Widget({ config }: { config: WidgetConfig }) {
  return (
    <WidgetErrorBoundary widgetId={config.id}>
      <WidgetRouter config={config} />
    </WidgetErrorBoundary>
  );
}

The retry button resets the error boundary state, giving the user a path to recovery without a full page reload. In practice, most render errors in widget components are caused by unexpected null data shapes from an API — a retry that triggers a fresh query often resolves them.

Drag and resize

The drag-and-resize mechanics are ultimately secondary to the data layer. react-grid-layout handles positioning well: it takes a layout array, renders children into grid slots, and calls onLayoutChange whenever the user moves or resizes a widget. The implementation is straightforward.

import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';

function Dashboard({ widgets, layouts, onLayoutChange }: DashboardProps) {
  return (
    <GridLayout
      layout={layouts}
      cols={12}
      rowHeight={60}
      width={1200}
      onLayoutChange={onLayoutChange}
      draggableHandle=".widget-drag-handle"
    >
      {widgets.map(config => (
        <div key={config.id}>
          <div className="widget-drag-handle cursor-grab">
            <span>{config.title}</span>
          </div>
          <Widget config={config} />
        </div>
      ))}
    </GridLayout>
  );
}

The draggableHandle prop is worth noting. Without it, clicking anywhere on a widget initiates a drag, which conflicts with interactive elements like charts, buttons, and input fields inside widgets. Restricting drag to a designated handle — a title bar or grip icon — resolves the interaction conflict cleanly.

Persisting layout state

Layout persistence is a two-layer problem: immediate persistence so a page refresh doesn't discard the arrangement, and server-side persistence so the layout follows the user across devices. These require different implementations but compose without conflict.

For immediate persistence, localStorage works well and requires no network round-trip:

function useDashboardLayout(initialLayouts: WidgetLayout[]) {
  const storageKey = 'dashboard-layout';
  const [layouts, setLayouts] = useState<WidgetLayout[]>(() => {
    try {
      const stored = localStorage.getItem(storageKey);
      return stored ? JSON.parse(stored) : initialLayouts;
    } catch {
      return initialLayouts;
    }
  });

  const handleLayoutChange = (newLayouts: WidgetLayout[]) => {
    setLayouts(newLayouts);
    localStorage.setItem(storageKey, JSON.stringify(newLayouts));
  };

  return { layouts, handleLayoutChange };
}

For cross-device persistence, treating layout as server state — fetched with useQuery and updated with useMutation — fits naturally into the existing TanStack Query setup. The debounce on save prevents a network request for every pixel moved during a drag.

function usePersistedLayout(userId: string, initialLayouts: WidgetLayout[]) {
  const { data: layouts } = useQuery({
    queryKey: ['dashboard-layout', userId],
    queryFn: () => fetchUserLayout(userId),
    initialData: initialLayouts,
  });

  const { mutate: saveLayout } = useMutation({
    mutationFn: (newLayouts: WidgetLayout[]) =>
      saveUserLayout(userId, newLayouts),
  });

  const debouncedSave = useMemo(
    () => debounce(saveLayout, 1000),
    [saveLayout]
  );

  return { layouts, handleLayoutChange: debouncedSave };
}

Real-time updates

The refetchInterval option gives each widget independent control over how frequently it polls for new data. An activity feed might need updates every ten seconds. A monthly revenue summary might be fine refreshing once per hour. Global polling — the common alternative — forces every widget onto the same interval, which typically means either over-fetching to satisfy the most demanding widget, or under-fetching and leaving time-sensitive widgets stale.

const WIDGET_REFRESH_INTERVALS: Record<WidgetConfig['type'], number | undefined> = {
  'activity-feed': 10_000,
  'orders': 30_000,
  'revenue': 3_600_000,
  'users': 300_000,
};

const config: WidgetConfig = {
  id: 'orders-widget',
  type: 'orders',
  title: 'Recent Orders',
  queryKey: ['orders'],
  refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};

But in reality, indiscriminate polling creates its own problem: a dashboard with many widgets polling short intervals generates substantial network traffic, including when the browser tab sits in the background. TanStack Query provides a direct solution with refetchIntervalInBackground: false, which pauses polling for hidden tabs. Combined with refetchOnWindowFocus: true (enabled by default), most use cases are covered: data stays fresh when the user is actively working, and network requests pause when they're not.

The widget router and global configuration

A central component mapping widget types to their implementations keeps the rendering logic in one place. Adding a new widget type means writing the component and adding one case — nothing else changes.

function WidgetRouter({ config }: { config: WidgetConfig }) {
  switch (config.type) {
    case 'revenue':      return <RevenueWidget config={config} />;
    case 'orders':       return <OrdersWidget config={config} />;
    case 'users':        return <UsersWidget config={config} />;
    case 'activity-feed': return <ActivityFeedWidget config={config} />;
    default:             return <div>Unknown widget type</div>;
  }
}

The global QueryClient configuration sets defaults that individual widgets can override. For a dashboard context, a 30-second default staleTime prevents constant background refetching while still keeping data reasonably current after a user navigates away and returns.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 2,
      refetchOnWindowFocus: true,
      refetchIntervalInBackground: false,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
    </QueryClientProvider>
  );
}

What this architecture prevents

The problems this architecture prevents are as important as what it enables. Global loading states become unnecessary — there is no moment at which "all data" is loading, only individual widgets. Cascading failures become structurally impossible — one widget's error cannot propagate to others because each error boundary and each query are scoped to a single widget. Adding new widgets doesn't require touching shared state or coordinating data requirements with existing widgets.

Performance profiling also becomes tractable. Network activity is clearly attributed to individual widgets via their query keys, which appear in the TanStack Query DevTools. Finding that one widget is making excessive requests, or that a particular query is never actually used, takes seconds rather than requiring a network waterfall analysis.

The drag-and-resize mechanics — the visible, interactive part that makes dashboards feel polished — are genuinely secondary to this data foundation. Any capable grid library handles positioning. What determines whether a widget dashboard holds up in production is whether the data layer is designed to fail gracefully and update independently. This architecture makes that design natural rather than effortful.

For teams building client portals or internal analytics tools, the pattern generalizes without modification. The widget abstraction doesn't require a complex drag-and-drop UI to be valuable — even a simple configurable grid of data cards benefits from query isolation and per-widget error boundaries. The grid layout is an enhancement; the data architecture is the foundation.

Related Insights

Further Reading