Construindo um painel de widgets personalizável com TanStack Query
A maioria dos painéis de produto é construída da mesma forma: um componente de nível superior dispara dez chamadas de API, aguarda todas elas e renderiza tudo de uma vez. Em uma demonstração, isso parece funcionar bem. Em produção, cria problemas que se agravam com o tempo. Um único endpoint lento bloqueia toda a tela. Um erro transitório em um widget compromete a tela inteira. Atualizações em tempo real exigem novas consultas coordenadas que acabam se tornando impossíveis de manter. Adicionar um novo widget exige lidar com um estado global que nunca foi projetado para acomodá-lo.
O TanStack Query (anteriormente React Query) resolve isso tratando a busca de dados como uma responsabilidade central, em vez de um recurso improvisado sobre o estado do componente. Cada consulta tem sua própria chave de cache, ciclo de vida e estado de erro. Em um painel de widgets, a aplicação é direta: cada widget gerencia seus dados e seus próprios estados de carregamento, erro e sucesso. O restante da aplicação não precisa conhecer nem gerenciar esses detalhes.
Separando layout e dados
Antes de escrever qualquer lógica de busca de dados, a arquitetura exige uma separação clara entre duas responsabilidades: quais dados um widget busca e onde ele fica. Elas podem mudar de forma independente — um usuário pode mover um widget sem alterar os dados exibidos, e o backend pode retornar novos dados sem afetar o layout. Misturá-las cria problemas de coordenação mais adiante.
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 de WidgetConfig faz a ligação com o cache do TanStack Query. Dois widgets com o mesmo queryKey compartilham 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 painéis compostos em que várias visualizações exibem os mesmos dados subjacentes.
Busca de dados por widget
Cada componente de widget chama useQuery com sua própria configuração. O staleTime informa ao TanStack Query por quanto tempo considerar os dados em cache atualizados antes de marcá-los como desatualizados. Configurar esse valor corretamente para cada tipo de widget evita novas consultas desnecessárias e 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>
);
}
Os estados de carregamento com skeleton e de erro são intencionalmente locais a cada widget. Não há indicador global de carregamento nem tratamento de erros no nível da aplicação que capture as falhas dos widgets. Cada widget apresenta seu próprio estado ao usuário — portanto, um widget de receita com skeleton de carregamento ao lado de um widget de pedidos com dados atuais é o comportamento correto e esperado, não um sinal de falha.
Isolamento de erros por widget
O estado isError de useQuery trata erros de busca de dados, mas erros de renderização — lançados durante a execução do componente — exigem o mecanismo de isolamento de erros (error boundary) do React. Envolver cada widget em seu próprio error boundary impede que uma falha de renderização derrube todo o painel. O usuário continua trabalhando com os outros widgets enquanto o que falhou exibe um estado de erro do qual é possível se recuperar.
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>
);
}
O botão de nova tentativa redefine o estado do error boundary, permitindo a recuperação sem recarregar toda a página. Na prática, a maioria dos erros de renderização em componentes de widgets é causada por estruturas de dados inesperadas com valores nulos retornados por uma API — uma nova tentativa que dispara outra consulta frequentemente os resolve. Observe que redefinir o boundary apenas renderiza o widget novamente; isso não busca novos dados por si só e, se os dados em cache ainda estiverem atualizados, o widget será renderizado novamente com os mesmos dados. Para tentar novamente com uma nova consulta, também redefina a consulta do widget ou busque seus dados novamente no manipulador da nova tentativa (por exemplo, com queryClient.resetQueries({ queryKey: config.queryKey })), ou use o QueryErrorResetBoundary do TanStack Query quando os erros de consulta forem lançados para o boundary.
Arrastar e redimensionar
Os mecanismos de arrastar e redimensionar são secundários em relação à camada de dados. O react-grid-layout gerencia bem o posicionamento: recebe um array de layout, renderiza os elementos filhos nas posições da grade e chama onLayoutChange sempre que o usuário move ou redimensiona um widget. A implementação é simples.
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>
);
}
A prop draggableHandle merece atenção. Sem ela, clicar em qualquer parte de um widget inicia o arraste, o que entra em conflito com elementos interativos como gráficos, botões e campos de entrada. Restringir o arraste a uma área específica — uma barra de título ou um ícone de arraste — resolve esse conflito de interação.
Persistência do estado do layout
A persistência do layout tem duas camadas: persistência imediata, para que uma atualização da página não descarte a organização, e persistência no servidor, para que o layout acompanhe o usuário entre dispositivos. Elas exigem implementações diferentes e precisam ser combinadas de forma deliberada para que a cópia local e a cópia no servidor não sobrescrevam uma à outra.
Para persistência imediata, o localStorage funciona bem e dispensa comunicação com o servidor:
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 };
}
Para persistência entre dispositivos, tratar o layout como estado do servidor — consultado com useQuery e atualizado com useMutation — se encaixa naturalmente na configuração existente do TanStack Query. O debounce ao salvar evita uma requisição de rede a cada pixel percorrido durante o arraste. O hook abaixo apenas salva; para manter os layouts retornados alinhados à disposição que o usuário acabou de definir, também grave o novo layout no cache (por exemplo, com queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) ou invalide a consulta após o salvamento ser concluído com sucesso.
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 };
}
Atualizações em tempo real
A opção refetchInterval permite que cada widget controle de forma independente a frequência das consultas por novos dados. Um feed de atividades pode precisar de atualizações a cada dez segundos. Um resumo de receita mensal pode funcionar bem com uma atualização por hora. A consulta periódica global — a alternativa comum — força todos os widgets a usar o mesmo intervalo, o que geralmente significa buscar dados em excesso para atender ao widget mais exigente ou buscar de menos e deixar desatualizados os widgets que precisam de dados recentes.
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'],
};
Na prática, porém, consultas periódicas indiscriminadas criam outro problema: um painel com muitos widgets consultando dados em intervalos curtos gera tráfego de rede significativo, inclusive quando a aba do navegador está em segundo plano. O TanStack Query oferece uma solução direta com refetchIntervalInBackground: false, que pausa as consultas em abas ocultas. Combinado com refetchOnWindowFocus: true (ativado por padrão), isso cobre a maioria dos casos de uso: os dados permanecem atuais enquanto o usuário trabalha, e as requisições de rede são pausadas quando ele não está ativo.
Roteamento de widgets e configuração global
Um componente central que associa os tipos de widget às respectivas implementações mantém a lógica de renderização em um só lugar. Adicionar um novo tipo de widget significa escrever o componente e adicionar um caso — nada mais muda.
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>;
}
}
A configuração global de QueryClient define padrões que cada widget pode substituir. Em um painel, um staleTime padrão de 30 segundos evita novas consultas constantes em segundo plano e mantém os dados razoavelmente atuais quando o usuário sai da página e retorna.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
O que essa arquitetura evita
Os problemas que essa arquitetura evita são tão importantes quanto os recursos que viabiliza. Estados globais de carregamento se tornam desnecessários — não há um momento em que “todos os dados” estejam carregando, apenas widgets individuais. Falhas em cascata se tornam muito menos prováveis — um erro de renderização de um widget não pode fazer os demais falharem porque cada error boundary fica restrito a um único widget, e widgets com chaves de consulta diferentes não compartilham o estado de busca dos dados. Widgets que compartilham deliberadamente uma chave de consulta também compartilham seus dados e seu estado de erro, de modo que uma falha nessa consulta afeta todos os widgets que usam essa chave. Adicionar novos widgets não exige alterar o estado compartilhado nem coordenar os requisitos de dados com os widgets existentes.
A análise de desempenho também se torna mais simples. A atividade de rede pode ser atribuída claramente a cada widget por meio das chaves de consulta, que aparecem no TanStack Query DevTools. Identificar um widget que faz requisições excessivas ou uma consulta que nunca é usada leva segundos, sem exigir uma análise da sequência temporal das requisições de rede.
Os mecanismos de arrastar e redimensionar — a parte visível e interativa que dá acabamento aos painéis — são secundários em relação a essa base de dados. Qualquer biblioteca de grade competente gerencia o posicionamento. O que determina se um painel de widgets funciona bem em produção é uma camada de dados projetada para lidar com falhas sem comprometer o conjunto e se atualizar de forma independente. Essa arquitetura torna esse desenho natural, sem exigir esforço adicional.
Para equipes que constroem portais de clientes ou ferramentas internas de análise de dados, o padrão pode ser aplicado sem alterações. A abstração de widgets não precisa de uma interface complexa de arrastar e soltar para ser útil — até uma grade simples e configurável de cartões de dados se beneficia do isolamento das consultas e dos error boundaries por widget. O layout em grade é uma melhoria; a arquitetura de dados é a base.
Artigos relacionados
- Implantando aplicações React em produção: configuração completa do Docker com proxy reverso Traefik — depois de construir o painel, a implantação em contêineres com Traefik gerencia HTTPS e roteamento sem gerenciamento manual de certificados.
- Otimização de desempenho do WooCommerce: de 8 segundos para 0,2 segundo de carregamento — as decisões de arquitetura de busca de dados têm efeitos mensuráveis no desempenho; os mesmos princípios dos widgets de painel se aplicam a qualquer frontend que trabalhe com muitos dados.
- Construindo infraestrutura de dados pronta para produção para vendedores da amazon: apresentando o tva-fetch — a infraestrutura de dados do backend que alimenta um painel determina a qualidade do que o TanStack Query pode armazenar em cache e fornecer.
- Resposta à CVE-2025-55182: nossa experiência com a vulnerabilidade dos React Server Components — a expansão dos recursos do React no servidor introduz uma superfície de ataque que arquiteturas de painel no cliente, como esta, evitam deliberadamente.