tva
← Insights

Construindo um Dashboard de Widgets Personalizável com TanStack Query

A maioria dos dashboards de produto é construída da mesma forma: um componente de nível superior dispara dez chamadas de API, aguarda todas elas e então renderiza tudo de uma vez. Em uma demonstração, isso parece bem. Em produção, cria uma classe de problemas que se agravam com o tempo. Um único endpoint lento bloqueia toda a view. Um erro transitório em um widget quebra toda a tela. Atualizações em tempo real requerem refetching coordenado que eventualmente se torna impossível de manter. Adicionar um novo widget significa raciocinar sobre estado global que nunca foi projetado para acomodá-lo.

O TanStack Query (anteriormente React Query) resolve isso tratando a busca de dados como uma preocupação de primeira classe, não como um afterthought colado ao estado do componente. Cada query tem sua própria chave de cache, ciclo de vida e estado de erro. Em um dashboard de widgets, isso se traduz diretamente: cada widget possui seus dados e é responsável por seus próprios estados de carregamento, erro e sucesso. O resto da aplicação não precisa saber ou se importar.

Separando Layout dos Dados

Antes de escrever qualquer lógica de busca de dados, a arquitetura requer uma separação clara entre duas preocupações: o que um widget busca e onde ele fica. Elas são independentemente mutáveis — um usuário pode mover um widget sem alterar os dados que ele exibe, e o backend pode retornar novos dados sem afetar o layout. Misturá-las cria problemas de coordenação mais tarde.

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

O campo queryKey no WidgetConfig é a ponte para o cache do TanStack Query. Dois widgets com o mesmo queryKey compartilharão um resultado em cache — uma requisição de rede, dois consumidores. Dois widgets com chaves diferentes são totalmente independentes. Essa distinção importa ao construir dashboards compostos onde múltiplas views exibem os mesmos dados subjacentes.

Busca de Dados por Widget

Cada componente de widget chama useQuery com sua própria configuração. O staleTime diz ao TanStack Query por quanto tempo tratar os dados em cache como atuais antes de marcá-los como obsoletos. Configurar isso corretamente por tipo de widget evita refetching desnecessário enquanto mantém os dados razoavelmente atuais.

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.

Insights Relacionados

Artigos relacionados