tva
← Insights

Ein anpassbares Widget-Dashboard mit TanStack Query aufbauen

Die meisten Produkt-Dashboards werden nach demselben Muster gebaut: Eine übergeordnete Komponente startet zehn API-Aufrufe, wartet auf alle Ergebnisse und rendert anschließend alles auf einmal. In einer Demo sieht das gut aus. Im Produktivbetrieb entstehen dadurch Probleme, die sich mit der Zeit verstärken. Ein einziger langsamer Endpunkt blockiert die gesamte Ansicht. Ein vorübergehender Fehler in einem Widget legt den ganzen Bildschirm lahm. Echtzeitaktualisierungen erfordern koordinierte erneute Datenabrufe, deren Wartung irgendwann unmöglich wird. Für ein neues Widget muss ein globaler Zustand verstanden werden, der nie dafür ausgelegt war.

TanStack Query (früher React Query) löst dies, indem es den Datenabruf als eigenständige Kernaufgabe behandelt, statt ihn nachträglich an den Komponentenzustand anzuhängen. Jede Abfrage hat ihren eigenen Cache-Schlüssel, Lebenszyklus und Fehlerzustand. Auf ein Widget-Dashboard übertragen heißt das: Jedes Widget verwaltet seine Daten und ist für seine eigenen Lade-, Fehler- und Erfolgszustände verantwortlich. Der Rest der Anwendung muss sich darum nicht kümmern.

Layout von Daten trennen

Bevor Sie Logik zum Datenabruf schreiben, braucht die Architektur eine klare Trennung zweier Aufgaben: welche Daten ein Widget abruft und wo es platziert ist. Beides lässt sich unabhängig verändern. Ein Nutzer kann ein Widget verschieben, ohne dessen angezeigte Daten zu ändern, und das Backend kann neue Daten liefern, ohne das Layout zu beeinflussen. Wer beides vermischt, schafft spätere Koordinationsprobleme.

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

Das Feld queryKey in WidgetConfig verbindet das Widget mit dem Cache von TanStack Query. Zwei Widgets mit demselben queryKey teilen sich ein zwischengespeichertes Ergebnis: eine Netzwerkanfrage, zwei nutzende Komponenten. Widgets mit unterschiedlichen Schlüsseln sind vollständig unabhängig. Diese Unterscheidung ist wichtig für zusammengesetzte Dashboards, in denen mehrere Ansichten dieselben zugrunde liegenden Daten zeigen.

Datenabruf pro Widget

Jede Widget-Komponente ruft useQuery mit ihrer eigenen Konfiguration auf. staleTime legt fest, wie lange TanStack Query zwischengespeicherte Daten als aktuell behandelt, bevor es sie als veraltet markiert. Eine passende Einstellung je Widget-Typ verhindert unnötige erneute Abrufe und hält die Daten zugleich hinreichend aktuell.

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

Ladeplatzhalter und Fehlerzustände bleiben bewusst auf das jeweilige Widget beschränkt. Es gibt weder einen globalen Ladeindikator noch eine Fehlerbehandlung auf Anwendungsebene, die Widget-Fehler abfängt. Jedes Widget zeigt dem Nutzer seinen eigenen Zustand. Ein Umsatz-Widget mit Ladeplatzhalter neben einem Bestell-Widget mit aktuellen Daten ist daher das erwartete, korrekte Verhalten und kein Anzeichen eines Defekts.

Fehlergrenzen pro Widget

Der Zustand isError von useQuery behandelt Abruffehler. Renderfehler, die während der Komponentenausführung ausgelöst werden, erfordern hingegen den Error-Boundary-Mechanismus von React. Eine eigene Fehlergrenze um jedes Widget verhindert, dass ein Renderfehler darin das gesamte Dashboard zum Absturz bringt. Der Nutzer arbeitet mit den anderen Widgets weiter, während das fehlerhafte Widget einen behebbaren Fehlerzustand zeigt.

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

Die Schaltfläche für einen erneuten Versuch setzt den Zustand der Fehlergrenze zurück. So kann der Nutzer den Fehler ohne vollständiges Neuladen der Seite beheben. In der Praxis entstehen die meisten Renderfehler in Widget-Komponenten durch unerwartete Nullwerte in den Daten einer API. Ein erneuter Versuch, der eine neue Abfrage auslöst, behebt sie oft. Beachten Sie, dass das Zurücksetzen der Fehlergrenze das Widget nur erneut rendert; es ruft nicht von selbst neue Daten ab. Sind die zwischengespeicherten Daten noch frisch, wird das Widget erneut mit denselben Daten gerendert. Für einen erneuten Versuch mit einer neuen Abfrage müssen Sie im Handler für den erneuten Versuch auch die Abfrage des Widgets zurücksetzen oder erneut abrufen (zum Beispiel mit queryClient.resetQueries({ queryKey: config.queryKey })). Alternativ können Sie QueryErrorResetBoundary von TanStack Query verwenden, wenn Abfragefehler an die Fehlergrenze geworfen werden.

Verschieben und Größe ändern

Die Mechanik zum Verschieben und Ändern der Größe ist gegenüber der Datenschicht letztlich zweitrangig. react-grid-layout übernimmt die Positionierung gut: Es erhält ein Layout-Array, rendert untergeordnete Komponenten in Rasterpositionen und ruft onLayoutChange auf, sobald der Nutzer ein Widget verschiebt oder seine Größe ändert. Die Umsetzung ist unkompliziert.

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

Die Eigenschaft draggableHandle verdient Beachtung. Ohne sie startet ein Klick an beliebiger Stelle des Widgets einen Ziehvorgang. Das kollidiert mit interaktiven Elementen wie Diagrammen, Schaltflächen und Eingabefeldern innerhalb des Widgets. Den Ziehvorgang auf einen vorgesehenen Griffbereich wie eine Titelleiste oder ein Griffsymbol zu beschränken, löst diesen Interaktionskonflikt sauber.

Layout-Zustand dauerhaft speichern

Die dauerhafte Layout-Speicherung hat zwei Ebenen: sofortiges Speichern, damit ein Neuladen der Seite die Anordnung nicht verwirft, und serverseitiges Speichern, damit das Layout dem Nutzer über mehrere Geräte hinweg folgt. Beide brauchen unterschiedliche Implementierungen und müssen bewusst so kombiniert werden, dass sich die lokale Kopie und die Kopie auf dem Server nicht gegenseitig überschreiben.

Für die sofortige Speicherung eignet sich localStorage gut und erfordert keine Netzwerkanfrage:

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

Für die geräteübergreifende Speicherung lässt sich das Layout als Serverzustand behandeln, der mit useQuery abgerufen und mit useMutation aktualisiert wird. Das passt direkt in die vorhandene TanStack-Query-Konfiguration. Eine Verzögerung beim Speichern mittels Debouncing verhindert eine Netzwerkanfrage für jedes Pixel, um das ein Widget beim Ziehen verschoben wird. Der folgende Hook speichert nur. Damit die zurückgegebenen layouts der gerade vom Nutzer erstellten Anordnung entsprechen, muss das neue Layout auch in den Cache geschrieben werden (zum Beispiel mit queryClient.setQueryData(['dashboard-layout', userId], newLayouts)), oder die Abfrage muss nach erfolgreichem Speichern invalidiert werden.

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

Echtzeitaktualisierungen

Mit refetchInterval steuert jedes Widget unabhängig, wie häufig es neue Daten abfragt. Ein Aktivitätsfeed benötigt vielleicht alle zehn Sekunden ein Update, während für eine monatliche Umsatzübersicht ein Abruf pro Stunde genügt. Globales Polling, die verbreitete Alternative, zwingt alle Widgets in dasselbe Intervall. Dadurch werden entweder zu viele Daten abgerufen, um das anspruchsvollste Widget zu bedienen, oder zu wenige, sodass zeitkritische Widgets veraltet bleiben.

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'],
};

Ungezieltes Polling schafft jedoch ein eigenes Problem: Viele Widgets mit kurzen Abrufintervallen verursachen erheblichen Netzwerkverkehr, auch wenn der Browser-Tab im Hintergrund liegt. TanStack Query bietet mit refetchIntervalInBackground: false eine direkte Lösung, die das Polling in ausgeblendeten Tabs pausiert. Zusammen mit refetchOnWindowFocus: true, das standardmäßig aktiviert ist, deckt dies die meisten Fälle ab: Daten bleiben aktuell, während der Nutzer aktiv arbeitet, und Netzwerkanfragen pausieren, wenn er es nicht tut.

Der Widget-Router und die globale Konfiguration

Eine zentrale Komponente, die Widget-Typen ihren Implementierungen zuordnet, hält die Renderlogik an einer Stelle. Für einen neuen Widget-Typ müssen Sie die Komponente schreiben und einen Fall ergänzen. Sonst ändert sich nichts.

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

Die globale QueryClient-Konfiguration legt Standardwerte fest, die einzelne Widgets überschreiben können. Bei einem Dashboard verhindert eine standardmäßige staleTime von 30 Sekunden ständige erneute Hintergrundabrufe. Gleichzeitig bleiben die Daten hinreichend aktuell, wenn ein Nutzer die Ansicht verlässt und zurückkehrt.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 2,
      refetchOnWindowFocus: true,
      refetchIntervalInBackground: false,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
    </QueryClientProvider>
  );
}

Was diese Architektur verhindert

Die vermiedenen Probleme sind ebenso wichtig wie die ermöglichten Funktionen. Globale Ladezustände werden überflüssig: Es gibt keinen Moment, in dem „alle Daten“ geladen werden, sondern nur einzelne ladende Widgets. Kaskadierende Fehler werden deutlich unwahrscheinlicher. Ein Renderfehler in einem Widget kann die anderen nicht zum Absturz bringen, weil jede Fehlergrenze auf ein einzelnes Widget beschränkt ist und Widgets mit unterschiedlichen Abfrageschlüsseln keinen gemeinsamen Abrufzustand haben. Widgets, die bewusst denselben Abfrageschlüssel verwenden, teilen jedoch auch dessen Daten- und Fehlerzustand. Ein Fehler dort betrifft daher jedes Widget, das diesen Schlüssel verwendet. Neue Widgets erfordern weder Änderungen am gemeinsamen Zustand noch eine Abstimmung ihrer Datenanforderungen mit bestehenden Widgets.

Auch Leistungsanalysen werden einfacher. Netzwerkaktivität lässt sich über die Abfrageschlüssel, die in den TanStack Query DevTools erscheinen, klar einzelnen Widgets zuordnen. Ob ein Widget übermäßig viele Anfragen stellt oder eine bestimmte Abfrage nie verwendet wird, lässt sich in Sekunden feststellen, statt eine Netzwerk-Wasserfallanalyse durchführen zu müssen.

Die Mechanik zum Verschieben und Ändern der Größe – der sichtbare, interaktive Teil, der Dashboards ausgereift wirken lässt – ist gegenüber dieser Datengrundlage tatsächlich zweitrangig. Jede geeignete Rasterbibliothek beherrscht die Positionierung. Ob ein Widget-Dashboard im Produktivbetrieb funktioniert, hängt davon ab, ob seine Datenschicht Fehler kontrolliert behandeln und Daten unabhängig aktualisieren kann. Diese Architektur macht das zum natürlichen Bestandteil des Designs statt zu einer aufwendigen Zusatzaufgabe.

Für Teams, die Kundenportale oder interne Analysewerkzeuge bauen, lässt sich das Muster unverändert übertragen. Die Widget-Abstraktion braucht keine komplexe Drag-and-drop-Oberfläche, um nützlich zu sein. Schon ein einfaches konfigurierbares Raster aus Datenkarten profitiert von isolierten Abfragen und Fehlergrenzen pro Widget. Das Rasterlayout ist eine Ergänzung; die Datenarchitektur bildet die Grundlage.

Weiterführende Beiträge

Weiterführende Beiträge