tva
← Analyses

Créer un tableau de bord personnalisable avec des widgets et TanStack Query

La plupart des tableaux de bord d’applications sont construits de la même façon : un composant racine lance dix appels API, attend qu’ils soient tous terminés, puis affiche tout en même temps. En démonstration, cela fonctionne bien. En production, cela crée une catégorie de problèmes qui s’accumulent au fil du temps. Un seul point de terminaison lent bloque toute la vue. Une erreur transitoire sur un widget rend tout l’écran inutilisable. Les mises à jour en temps réel nécessitent une récupération coordonnée des données 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 faisant de la récupération de données une responsabilité à part entière, plutôt qu’un ajout après coup à 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 responsabilités : ce qu’un widget récupère et sa position. Elles peuvent évoluer indépendamment — un utilisateur peut déplacer un widget sans changer ce qu’il affiche, et le backend peut renvoyer 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 de WidgetConfig assure le lien avec 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. L’option staleTime indique à TanStack Query pendant combien de temps considérer les données en cache comme à jour avant de les marquer comme périmées. Un réglage adapté à chaque type de widget évite les récupérations inutiles tout en gardant les données suffisamment à jour.

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

Le squelette de chargement et l’état d’erreur sont volontairement propres à chaque widget. Il n’y a pas d’indicateur de chargement global, pas de gestionnaire d’erreurs au niveau de l’application qui capture les défaillances des widgets. Chaque widget présente son propre état à l’utilisateur — ce qui signifie qu’un widget de chiffre d’affaires 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, et ne signale aucun dysfonctionnement.

Barrières d’erreur par widget

L’état isError de useQuery gère les erreurs de récupération des données, mais les erreurs de rendu — levées pendant l’exécution des composants — nécessitent le mécanisme de barrière d’erreur de React. Envelopper chaque widget dans sa propre barrière 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 le widget défaillant affiche une erreur dont il est possible de sortir.

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 barrière d’erreur et permet à l’utilisateur de reprendre son travail sans rechargement complet de la page. En pratique, la plupart des erreurs de rendu dans les composants widgets sont causées par des structures de données contenant des valeurs nulles inattendues provenant d’une API — une nouvelle tentative qui relance la requête les résout souvent. Notez que la réinitialisation de la barrière ne fait que déclencher un nouveau rendu du widget ; elle ne récupère pas de nouvelles données à elle seule et, si les données en cache sont encore fraîches, le widget est rendu à nouveau avec les mêmes données. Pour réessayer avec une nouvelle requête, réinitialisez aussi la requête du widget ou relancez la récupération de ses données dans le gestionnaire de nouvelle tentative (par exemple avec queryClient.resetQueries({ queryKey: config.queryKey })), ou utilisez QueryErrorResetBoundary de TanStack Query lorsque les erreurs de requête sont levées vers la barrière.

Glisser-déposer et redimensionnement

Les mécanismes de déplacement et de redimensionnement sont finalement secondaires par rapport à la couche de données. react-grid-layout gère bien le positionnement : il reçoit un tableau décrivant la disposition et affiche les composants enfants dans les cases de la 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 propriété draggableHandle mérite d’être mentionnée. Sans elle, cliquer n’importe où sur un widget déclenche un déplacement, 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. Limiter le déplacement à une zone dédiée — une barre de titre ou une icône de poignée — résout proprement le conflit d’interaction.

Conserver l’état de la mise en page

La conservation de la mise en page comporte deux volets : une sauvegarde immédiate pour qu’un rechargement de la page ne perde pas la disposition, et une sauvegarde côté serveur pour que l’utilisateur la retrouve sur tous ses appareils. Ces deux volets nécessitent des implémentations différentes et doivent être combinés avec soin pour que la copie locale et la copie sur le serveur ne s’écrasent pas mutuellement.

Pour la sauvegarde 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 sauvegarde entre 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. La temporisation de la sauvegarde évite d’envoyer une requête réseau pour chaque pixel parcouru lors d’un déplacement. Le hook ci-dessous ne fait que sauvegarder ; pour que les layouts renvoyés reflètent la disposition que l’utilisateur vient de définir, écrivez aussi la nouvelle mise en page dans le cache (par exemple avec queryClient.setQueryData(['dashboard-layout', userId], newLayouts)) ou invalidez la requête après la réussite de la sauvegarde.

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 recherche de nouvelles données. Un fil d’activité peut avoir besoin de mises à jour toutes les dix secondes. Un récapitulatif mensuel du chiffre d’affaires peut se contenter d’une actualisation par heure. L’interrogation périodique globale — l’alternative courante — impose le même intervalle à tous les widgets. Cela entraîne généralement soit un excès de requêtes pour satisfaire le widget le plus exigeant, soit des requêtes trop espacées qui laissent les widgets sensibles à la fraîcheur des données afficher des informations périmées.

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é, l’interrogation périodique systématique crée son propre problème : un tableau de bord avec de nombreux widgets interrogeant les données à 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 suspend l’interrogation périodique pour les onglets masqués. Associé à refetchOnWindowFocus: true (activé par défaut), ce réglage couvre la plupart des cas d’utilisation : les données restent à jour 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 associant les types de widgets à leurs implémentations conserve 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 récupérations constantes de données en arrière-plan tout en maintenant les données raisonnablement à jour lorsqu’un utilisateur quitte la page puis y 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 beaucoup moins probables — une erreur de rendu dans un widget ne peut pas faire planter les autres, car chaque barrière d’erreur est limitée à un seul widget et les widgets dont les clés de requête diffèrent ne partagent pas l’état de récupération des données. Les widgets qui partagent délibérément une clé de requête partagent aussi ses données et son état d’erreur : une défaillance à ce niveau affecte donc tous les widgets qui utilisent cette clé. 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.

L’analyse des performances devient également plus simple. 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. Repérer un widget qui envoie trop de requêtes ou une requête qui 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écanismes de déplacement et de redimensionnement — la partie visible et interactive qui donne aux tableaux de bord leur aspect soigné — sont véritablement secondaires par rapport à cette base de données. Toute bibliothèque de grille adaptée 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 gérer les échecs sans interrompre le reste de l’application 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, cette approche se généralise sans modification. L’abstraction de widget ne nécessite pas une interface utilisateur complexe de glisser-déposer pour être utile — même une simple grille configurable de cartes de données bénéficie de l’isolation des requêtes et des barrières d’erreur par widget. La disposition en grille est une amélioration ; l’architecture de données est le fondement.

Analyses connexes

Pour aller plus loin