tva
← Insights

Costruire una Dashboard Widget Personalizzabile con TanStack Query

La maggior parte delle dashboard di prodotto è costruita allo stesso modo: un componente di livello superiore avvia dieci chiamate API, aspetta tutte, poi renderizza tutto in una volta. In una demo, sembra tutto bene. In produzione, crea una classe di problemi che si compongono nel tempo. Un singolo endpoint lento blocca l'intera vista. Un errore transitorio su un widget rompe l'intera schermata. Gli aggiornamenti in tempo reale richiedono un refetching coordinato che alla fine diventa impossibile da mantenere. Aggiungere un nuovo widget significa ragionare sullo stato globale che non è mai stato progettato per accoglierlo.

TanStack Query (precedentemente React Query) risolve questo trattando il data fetching come una preoccupazione di primo livello, non come un pensiero aggiunto allo stato del componente. Ogni query ha la propria chiave cache, ciclo di vita e stato di errore. In una dashboard widget, questo si traduce direttamente: ogni widget possiede i propri dati ed è responsabile dei propri stati di caricamento, errore e successo. Il resto dell'applicazione non ha bisogno di sapere o preoccuparsi.

Separare il layout dai dati

Prima di scrivere qualsiasi logica di data fetching, l'architettura richiede una chiara separazione tra due preoccupazioni: cosa recupera un widget e dove si trova. Queste sono indipendentemente mutabili — un utente può spostare un widget senza cambiare quali dati mostra, e il backend può restituire nuovi dati senza influenzare il layout. Confonderle crea problemi di coordinazione in seguito.

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

Il campo queryKey su WidgetConfig è il ponte verso la cache di TanStack Query. Due widget con lo stesso queryKey condivideranno un risultato memorizzato nella cache — una richiesta di rete, due consumatori. Due widget con chiavi diverse sono completamente indipendenti. Questa distinzione è importante quhen 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

Articoli correlati