Costruire una dashboard a 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, le attende tutte e poi visualizza tutto insieme. In una demo sembra funzionare bene. In produzione genera una serie di problemi che si aggravano nel tempo. Un solo endpoint lento blocca l’intera vista. Un errore transitorio in un widget compromette tutta la schermata. Gli aggiornamenti in tempo reale richiedono acquisizioni dei dati coordinate che alla lunga diventano impossibili da mantenere. Aggiungere un widget significa ragionare su uno stato globale che non è mai stato progettato per accoglierlo.
TanStack Query (in precedenza React Query) risolve il problema trattando il recupero dei dati come un aspetto centrale, anziché un’aggiunta allo stato dei componenti. Ogni query ha la propria chiave di cache, il proprio ciclo di vita e il proprio stato di errore. In una dashboard a widget, la corrispondenza è diretta: ogni widget gestisce i suoi dati e i relativi stati di caricamento, errore e successo. Il resto dell’applicazione non deve conoscerli né occuparsene.
Separare il layout dai dati
Prima di scrivere la logica di recupero dei dati, l’architettura richiede una separazione netta tra due aspetti: quali dati recupera un widget e dove si trova. Possono cambiare indipendentemente: un utente può spostare un widget senza modificarne i dati e il backend può restituire nuovi dati senza influire sul layout. Confonderli crea problemi di coordinamento 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 di WidgetConfig collega il widget alla cache di TanStack Query. Due widget con lo stesso queryKey condividono un risultato in cache: una richiesta di rete, due utilizzatori. Due widget con chiavi diverse sono completamente indipendenti. La distinzione conta nelle dashboard composite, dove più viste mostrano gli stessi dati sottostanti.
Recupero dei dati per singolo widget
Ogni componente widget chiama useQuery con la propria configurazione. staleTime indica a TanStack Query per quanto tempo considerare aggiornati i dati in cache prima di contrassegnarli come non aggiornati. Impostarlo correttamente per ogni tipo di widget evita nuove acquisizioni inutili mantenendo i dati ragionevolmente aggiornati.
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>
);
}
Gli scheletri di caricamento e gli stati di errore sono deliberatamente locali al widget. Non c’è un indicatore di caricamento globale né un gestore degli errori a livello di applicazione che intercetti i malfunzionamenti dei widget. Ognuno presenta all’utente il proprio stato: vedere un widget dei ricavi ancora in caricamento accanto a uno degli ordini che mostra dati aggiornati è il comportamento corretto e atteso, non il segno di un guasto.
Error boundary per singolo widget
Lo stato isError di useQuery gestisce gli errori di recupero dei dati, ma gli errori di rendering, sollevati durante l’esecuzione del componente, richiedono il meccanismo degli error boundary di React. Racchiudere ogni widget nel proprio error boundary impedisce che un errore di rendering blocchi l’intera dashboard. L’utente continua a lavorare con gli altri widget, mentre quello guasto mostra uno stato di errore dal quale è possibile recuperare.
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>
);
}
Il pulsante per riprovare reimposta lo stato dell’error boundary e consente il recupero senza ricaricare tutta la pagina. In pratica, la maggior parte degli errori di rendering nei widget deriva da dati null inattesi restituiti da un’API: un nuovo tentativo che avvia una nuova query spesso li risolve. Tieni presente che reimpostare il boundary provoca solo un nuovo rendering del widget: non recupera nuovi dati di per sé e, se i dati nella cache sono ancora freschi, il widget viene renderizzato di nuovo con gli stessi dati. Per riprovare con una nuova query, reimposta anche la query del widget o recuperane nuovamente i dati nel gestore del nuovo tentativo (ad esempio con queryClient.resetQueries({ queryKey: config.queryKey })), oppure usa QueryErrorResetBoundary di TanStack Query quando gli errori delle query vengono lanciati verso il boundary.
Trascinamento e ridimensionamento
Le meccaniche di trascinamento e ridimensionamento sono, in definitiva, secondarie rispetto al livello dati. react-grid-layout gestisce bene il posizionamento: riceve un array di layout, visualizza i componenti figli nelle celle della griglia e chiama onLayoutChange ogni volta che l’utente sposta o ridimensiona un widget. L’implementazione è semplice.
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>
);
}
La proprietà draggableHandle merita attenzione. Senza di essa, un clic in qualsiasi punto del widget avvia il trascinamento, entrando in conflitto con elementi interattivi come grafici, pulsanti e campi di input. Limitare il trascinamento a un’area apposita, come una barra del titolo o un’icona di presa, risolve il conflitto in modo semplice.
Persistenza dello stato del layout
La persistenza del layout ha due livelli: salvataggio immediato, per evitare che un aggiornamento della pagina perda la disposizione, e salvataggio sul server, per ritrovare il layout su dispositivi diversi. Richiedono implementazioni differenti e vanno combinati con attenzione, in modo che la copia locale e quella sul server non si sovrascrivano a vicenda.
Per il salvataggio immediato, localStorage funziona bene e non richiede richieste di rete:
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 };
}
Per la persistenza tra dispositivi, trattare il layout come stato del server, recuperato con useQuery e aggiornato con useMutation, si integra naturalmente nella configurazione TanStack Query esistente. Il debounce sul salvataggio evita una richiesta di rete per ogni pixel di spostamento durante il trascinamento. L’hook qui sotto si limita a salvare; per mantenere i layouts restituiti allineati alla disposizione appena creata dall’utente, scrivi anche il nuovo layout nella cache (ad esempio con queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) oppure invalida la query dopo il buon esito del salvataggio.
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 };
}
Aggiornamenti in tempo reale
L’opzione refetchInterval permette a ogni widget di controllare autonomamente la frequenza con cui richiede nuovi dati. Un flusso di attività può richiedere aggiornamenti ogni dieci secondi, mentre per un riepilogo mensile dei ricavi può bastare una volta all’ora. Il polling globale, l’alternativa comune, impone a tutti lo stesso intervallo: di solito si recuperano troppi dati per soddisfare il widget più esigente, oppure troppo pochi, lasciando non aggiornati quelli per cui la tempestività conta.
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'],
};
Il polling indiscriminato, però, crea un altro problema: molti widget che interrogano il server a intervalli brevi generano traffico di rete considerevole, anche quando la scheda del browser è in background. TanStack Query offre una soluzione diretta con refetchIntervalInBackground: false, che sospende il polling nelle schede nascoste. Insieme a refetchOnWindowFocus: true, attivo per impostazione predefinita, copre la maggior parte dei casi: i dati restano aggiornati quando l’utente lavora e le richieste di rete si fermano quando non è attivo.
Il router dei widget e la configurazione globale
Un componente centrale che associa i tipi di widget alle rispettive implementazioni mantiene la logica di rendering in un solo punto. Aggiungere un tipo di widget richiede di scriverne il componente e aggiungere un caso: il resto non cambia.
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>;
}
}
La configurazione globale di QueryClient definisce valori predefiniti che i singoli widget possono sovrascrivere. Per una dashboard, un valore predefinito di staleTime pari a 30 secondi evita recuperi continui in background, mantenendo i dati ragionevolmente aggiornati quando l’utente esce dalla pagina e poi torna.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
Quali problemi evita questa architettura
I problemi evitati contano quanto le possibilità offerte. Gli stati di caricamento globali diventano superflui: non c’è un momento in cui si caricano «tutti i dati», ma solo singoli widget. I guasti a cascata diventano molto meno probabili: un errore di rendering di un widget non può mandare in crash gli altri, perché ogni error boundary riguarda un singolo widget e i widget con chiavi di query diverse non condividono lo stato di recupero dei dati. I widget che condividono deliberatamente una chiave di query ne condividono anche i dati e lo stato di errore, quindi un guasto in quella query interessa tutti i widget che usano quella chiave. Aggiungere widget non richiede di modificare lo stato condiviso né di coordinare i requisiti sui dati con quelli già presenti.
Anche l’analisi delle prestazioni diventa più semplice. Le richieste di rete sono attribuite chiaramente ai singoli widget tramite le chiavi di query visibili nei TanStack Query DevTools. Individuare un widget che invia troppe richieste o una query mai utilizzata richiede pochi secondi, anziché un’analisi del diagramma a cascata delle richieste di rete.
Trascinamento e ridimensionamento, la parte visibile e interattiva che rende una dashboard curata, sono secondari rispetto a questa base dati. Qualsiasi buona libreria per griglie gestisce il posizionamento. La tenuta in produzione dipende da un livello dati progettato per gestire gli errori senza bloccare l’applicazione e aggiornarsi in modo indipendente. Questa architettura rende naturale tale progettazione.
Per i team che sviluppano portali clienti o strumenti interni di analisi, il modello si applica senza modifiche. L’astrazione del widget non richiede un’interfaccia complessa di trascinamento per essere utile: anche una semplice griglia configurabile di schede dati beneficia dell’isolamento delle query e degli error boundary per widget. Il layout a griglia è un miglioramento; l’architettura dei dati è la base.
Approfondimenti correlati
- Distribuire applicazioni React in produzione: configurazione Docker completa con reverse proxy Traefik — una volta realizzata la dashboard, la distribuzione in container con Traefik gestisce HTTPS e instradamento senza gestione manuale dei certificati.
- Ottimizzare le prestazioni di WooCommerce: da 8 secondi a 0,2 secondi di caricamento — le scelte sull’architettura di recupero dei dati hanno effetti misurabili sulle prestazioni; gli stessi principi dei widget valgono per qualsiasi frontend che gestisce molti dati.
- Costruire un’infrastruttura dati pronta per la produzione per i venditori Amazon: presentazione di tva-fetch — l’infrastruttura dati del backend che alimenta la dashboard determina la qualità di ciò che TanStack Query può archiviare in cache e fornire.
- Rispondere a CVE-2025-55182: la nostra esperienza con la vulnerabilità di React Server Components — l’espansione delle capacità di React sul server introduce una superficie di attacco che architetture di dashboard lato client come questa evitano deliberatamente.