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 values in API data — a retry that triggers a fresh query often resolves them. Note that resetting the boundary only re-renders the widget; it does not refetch by itself, and if the cached data is still fresh the widget renders again with the same data. To retry with a fresh query, also reset or refetch the widget's query in the retry handler (for example with queryClient.resetQueries({ queryKey: config.queryKey })), or use TanStack Query's QueryErrorResetBoundary when query errors are thrown to the boundary.

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 and need to be composed deliberately so that the local copy and the server copy do not overwrite each other.

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. The hook below only saves; to keep the returned layouts in step with what the user just arranged, also write the new layout into the cache (for example with queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) or invalidate the query after the save succeeds.

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 at 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 much less likely — one widget's render error cannot crash the others because each error boundary is scoped to a single widget, and widgets with different query keys do not share fetch state. Widgets that deliberately share a query key also share its data and error state, so a failure there affects every widget using that key. 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