Construire un tableau de bord de widgets personnalisables avec TanStack Query
La plupart des tableaux de bord de produits sont construits de la même façon : un composant de niveau supérieur lance dix appels API, attend qu'ils soient tous terminés, puis affiche tout en même temps. En démonstration, cela semble correct. En production, cela crée une classe de problèmes qui s'accumulent au fil du temps. Un seul endpoint lent bloque toute la vue. Une erreur transitoire sur un widget casse tout l'écran. Les mises à jour en temps réel nécessitent un rafetching coordonné qui finit par devenir impossible à maintenir. Ajouter un nouveau widget nécessite de raisonner sur un état global qui n'a jamais été conçu pour l'accueillir.
TanStack Query (anciennement React Query) résout cela en traitant la récupération de données comme une préoccupation de premier plan, et non comme une pensée secondaire greffée sur l'état des composants. Chaque requête a sa propre clé de cache, son cycle de vie et son état d'erreur. Dans un tableau de bord de widgets, cela se traduit directement : chaque widget possède ses données et est responsable de ses propres états de chargement, d'erreur et de succès. Le reste de l'application n'a pas besoin de le savoir ni de s'en préoccuper.
Séparer la mise en page des données
Avant d'écrire la moindre logique de récupération de données, l'architecture nécessite une séparation claire entre deux préoccupations : ce qu'un widget récupère et où il se trouve. Celles-ci sont indépendamment mutables — un utilisateur peut déplacer un widget sans changer ce qu'il affiche, et le backend peut retourner de nouvelles données sans affecter la mise en page. Les confondre crée des problèmes de coordination plus tard.
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;
}
Le champ queryKey sur WidgetConfig est le pont vers le cache de TanStack Query. Deux widgets avec le même queryKey partageront un résultat mis en cache — une requête réseau, deux consommateurs. Deux widgets avec des clés différentes sont entièrement indépendants. Cette distinction compte lors de la construction de tableaux de bord composites où plusieurs vues affichent les mêmes données sous-jacentes.
Récupération de données par widget
Chaque composant widget appelle useQuery avec sa propre configuration. Le staleTime indique à TanStack Query combien de temps traiter les données en cache comme actuelles avant de les marquer comme périmées. Définir cela correctement par type de widget évite les refetchings inutiles tout en maintenant les données raisonnablement fraîches.
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 période précédente : {data.changePercent}%</p>
</div>
);
}
Les états de squelette et d'erreur sont intentionnellement locaux au widget. Il n'y a pas de spinner de chargement global, pas de gestionnaire d'erreurs au niveau application qui capture les défaillances des widgets. Chaque widget présente son propre état à l'utilisateur — ce qui signifie qu'un widget de revenus affichant un squelette de chargement aux côtés d'un widget de commandes affichant des données en direct est le comportement attendu et correct, pas le signe de quelque chose de cassé.
Limites d'erreur par widget
L'état isError de useQuery gère les erreurs de fetch, mais les erreurs de rendu — levées pendant l'exécution des composants — nécessitent le mécanisme de limite d'erreur de React. Envelopper chaque widget dans sa propre limite signifie qu'un widget qui lève une exception lors du rendu ne plante pas tout le tableau de bord. L'utilisateur continue à travailler avec ses autres widgets tandis que celui qui est cassé affiche un état d'erreur récupérable.
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>Ce widget a rencontré une erreur.</p>
<button onClick={() => this.setState({ hasError: false })}>
Réessayer
</button>
</div>
);
}
return this.props.children;
}
}
function Widget({ config }: { config: WidgetConfig }) {
return (
<WidgetErrorBoundary widgetId={config.id}>
<WidgetRouter config={config} />
</WidgetErrorBoundary>
);
}
Le bouton « Réessayer » réinitialise l'état de la limite d'erreur, offrant à l'utilisateur un chemin de récupération sans rechargement complet de la page. En pratique, la plupart des erreurs de rendu dans les composants widgets sont causées par des formes de données nulles inattendues venant d'une API — une nouvelle tentative déclenchant une requête fraîche les résout souvent.
Glisser-déposer et redimensionnement
Les mécaniques de glisser-redimensionner sont finalement secondaires par rapport à la couche de données. react-grid-layout gère bien le positionnement : il prend un tableau de mise en page, rend les enfants dans des emplacements de grille, et appelle onLayoutChange chaque fois que l'utilisateur déplace ou redimensionne un widget. L'implémentation est simple.
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 prop draggableHandle mérite d'être mentionnée. Sans elle, cliquer n'importe où sur un widget initie un glissement, ce qui entre en conflit avec les éléments interactifs comme les graphiques, les boutons et les champs de saisie à l'intérieur des widgets. Restreindre le glissement à une poignée désignée — une barre de titre ou une icône de prise — résout proprement le conflit d'interaction.
Persister l'état de mise en page
La persistance de la mise en page est un problème à deux couches : persistance immédiate pour qu'un rafraîchissement de page ne supprime pas l'arrangement, et persistance côté serveur pour que la mise en page suive l'utilisateur sur tous les appareils. Celles-ci nécessitent des implémentations différentes mais se composent sans conflit.
Pour la persistance immédiate, localStorage fonctionne bien et ne nécessite pas d'aller-retour réseau :
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 };
}
Pour la persistance multi-appareils, traiter la mise en page comme un état serveur — récupéré avec useQuery et mis à jour avec useMutation — s'intègre naturellement dans la configuration TanStack Query existante. Le debounce sur la sauvegarde évite une requête réseau pour chaque pixel déplacé lors d'un glissement.
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 };
}
Mises à jour en temps réel
L'option refetchInterval donne à chaque widget un contrôle indépendant sur la fréquence à laquelle il interroge de nouvelles données. Un fil d'activité peut avoir besoin de mises à jour toutes les dix secondes. Un résumé mensuel des revenus peut être satisfait d'un rafraîchissement toutes les heures. Le polling global — l'alternative courante — force chaque widget sur le même intervalle, ce qui signifie généralement soit un sur-fetching pour satisfaire le widget le plus exigeant, soit un sous-fetching laissant les widgets sensibles au temps périmés.
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: 'Commandes récentes',
queryKey: ['orders'],
refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};
Mais en réalité, le polling sans discrimination crée son propre problème : un tableau de bord avec de nombreux widgets sondant à des intervalles courts génère un trafic réseau substantiel, y compris lorsque l'onglet du navigateur est en arrière-plan. TanStack Query fournit une solution directe avec refetchIntervalInBackground: false, qui met en pause le polling pour les onglets masqués. Combiné avec refetchOnWindowFocus: true (activé par défaut), la plupart des cas d'utilisation sont couverts : les données restent fraîches lorsque l'utilisateur travaille activement, et les requêtes réseau s'arrêtent quand ce n'est pas le cas.
Le routeur de widgets et la configuration globale
Un composant central mappant les types de widgets à leurs implémentations garde la logique de rendu en un seul endroit. Ajouter un nouveau type de widget signifie écrire le composant et ajouter un cas — rien d'autre ne change.
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>Type de widget inconnu</div>;
}
}
La configuration globale du QueryClient définit des valeurs par défaut que les widgets individuels peuvent remplacer. Dans un contexte de tableau de bord, un staleTime par défaut de 30 secondes évite les refetchings constants en arrière-plan tout en maintenant les données raisonnablement à jour après qu'un utilisateur s'est éloigné et revient.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
refetchIntervalInBackground: false,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
Ce que cette architecture prévient
Les problèmes que cette architecture prévient sont aussi importants que ce qu'elle permet. Les états de chargement globaux deviennent inutiles — il n'y a jamais de moment où « toutes les données » se chargent, seulement des widgets individuels. Les défaillances en cascade deviennent structurellement impossibles — l'erreur d'un widget ne peut pas se propager aux autres car chaque limite d'erreur et chaque requête sont limitées à un seul widget. Ajouter de nouveaux widgets ne nécessite pas de toucher à l'état partagé ni de coordonner les exigences de données avec les widgets existants.
Le profilage de performance devient également tractable. L'activité réseau est clairement attribuée aux widgets individuels via leurs clés de requête, qui apparaissent dans les DevTools TanStack Query. Trouver qu'un widget fait des requêtes excessives, ou qu'une requête particulière n'est jamais réellement utilisée, prend quelques secondes plutôt que de nécessiter une analyse de la cascade réseau.
Les mécaniques de glisser-redimensionner — la partie visible et interactive qui donne aux tableaux de bord leur aspect soigné — sont véritablement secondaires par rapport à cette fondation de données. N'importe quelle bibliothèque de grille capable gère le positionnement. Ce qui détermine si un tableau de bord de widgets tient la route en production, c'est si la couche de données est conçue pour échouer gracieusement et se mettre à jour indépendamment. Cette architecture rend cette conception naturelle plutôt que laborieuse.
Pour les équipes construisant des portails clients ou des outils analytiques internes, le pattern se généralise sans modification. L'abstraction de widget ne nécessite pas une interface utilisateur complexe de glisser-déposer pour être précieuse — même une simple grille configurable de cartes de données bénéficie de l'isolation des requêtes et des limites d'erreur par widget. La mise en page de grille est un enrichissement ; l'architecture de données est le fondement.
Insights connexes
- Deploying React Applications to Production: Complete Docker Setup with Traefik Reverse Proxy — une fois le tableau de bord construit, le déploiement conteneurisé avec Traefik gère le HTTPS et le routage sans gestion manuelle des certificats.
- WooCommerce Performance Optimization: From 8 Seconds to 0.2 Seconds Load Time — les décisions d'architecture de récupération de données ont des conséquences mesurables sur les performances ; les mêmes principes qui s'appliquent aux widgets de tableau de bord s'appliquent à tout frontend riche en données.
- Building Production-Ready Data Infrastructure for Amazon Sellers: Introducing tva-fetch — l'infrastructure de données backend qui alimente un tableau de bord détermine la qualité de ce que TanStack Query peut mettre en cache et servir.
- Responding to CVE-2025-55182: Our Experience with the React Server Components Vulnerability — les capacités côté serveur croissantes de React introduisent une surface d'attaque que les architectures de tableau de bord côté client comme celle-ci évitent délibérément.