M-Bus Gateway
← Tilbage til blog
· React· TanStack Query· useQuery· SSE· EventSource· polling· IoT· dashboard· TypeScript

React useQuery til realtids IoT dashboard — polling og SSE

React TanStack Query til IoT dashboard: useQuery polling, SSE med EventSource, optimistiske opdateringer, fejlhåndtering og cache-koordinering for realtidsdata.

Af M-Bus Gateway

Et IoT dashboard kombinerer polling (dashboard-data), SSE (alarmer) og mutations (kommandoer). Her er arkitekturen brugt i M-Bus Gateway portalen.


Polling: Dashboard data hvert 30. sekund

// ui/src/pages/portal/PortalDashboard.tsx

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api";

export function PortalDashboard() {
  const {
    data: dashboard,
    isLoading,
    isError,
    dataUpdatedAt,
  } = useQuery({
    queryKey: ["dashboard"],
    queryFn: api.portal.getDashboard,
    refetchInterval: 30_000,           // Poll hvert 30. sek
    refetchIntervalInBackground: false, // Stop polling når tab er i baggrund
    staleTime: 25_000,                 // Data frisk i 25 sek
    retry: 3,
    retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
  });

  return (
    <div className="dashboard">
      {isLoading && <Spinner />}
      {isError && <ErrorBanner message="Kan ikke hente dashboard-data" />}
      {dashboard && (
        <>
          <KpiGrid data={dashboard} />
          <LastUpdated timestamp={dataUpdatedAt} />
        </>
      )}
    </div>
  );
}

function LastUpdated({ timestamp }: { timestamp: number }) {
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
  return (
    <div className="last-updated">
      Opdateret {seconds < 5 ? "lige nu" : `${seconds}s siden`}
    </div>
  );
}

SSE: Realtids alarmer via EventSource

// ui/src/hooks/useAlarmStream.ts

import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";

interface AlarmEvent {
  id: string;
  type: "critical" | "warning" | "info";
  message: string;
  gateway_id: string;
  timestamp: string;
}

export function useAlarmStream(enabled = true) {
  const queryClient = useQueryClient();
  const esRef = useRef<EventSource | null>(null);

  useEffect(() => {
    if (!enabled) return;

    const token = localStorage.getItem("access_token");
    const url = `/api/v1/alarms/stream?token=${token}`;

    esRef.current = new EventSource(url);

    esRef.current.onmessage = (event) => {
      const alarm: AlarmEvent = JSON.parse(event.data);

      // Tilføj ny alarm til cache
      queryClient.setQueryData<AlarmEvent[]>(["alarms"], (prev = []) =>
        [alarm, ...prev].slice(0, 50) // Max 50 alarmer i cache
      );

      // Invalidér dashboard (alarmbadge skal opdateres)
      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
    };

    esRef.current.onerror = () => {
      // Reconnect automatisk (browser håndterer dette for EventSource)
      // Men vi kan sætte en max-retry policy:
      esRef.current?.close();
      setTimeout(() => {
        if (enabled) {
          // Trigger re-mount af effect
        }
      }, 5_000);
    };

    return () => {
      esRef.current?.close();
    };
  }, [enabled, queryClient]);
}

Mutation med optimistisk opdatering

// ui/src/pages/portal/Alarms.tsx

import { useMutation, useQueryClient } from "@tanstack/react-query";

interface Alarm {
  id: string;
  acknowledged: boolean;
  message: string;
  severity: "critical" | "warning";
}

function AlarmRow({ alarm }: { alarm: Alarm }) {
  const queryClient = useQueryClient();

  const acknowledgeMutation = useMutation({
    mutationFn: (alarmId: string) =>
      api.alarms.acknowledge(alarmId),

    // Optimistisk: Opdatér UI straks uden at vente på server
    onMutate: async (alarmId) => {
      // Annullér igangværende refetches
      await queryClient.cancelQueries({ queryKey: ["alarms"] });

      // Gem gammel data til rollback
      const previousAlarms = queryClient.getQueryData<Alarm[]>(["alarms"]);

      // Optimistisk opdatering
      queryClient.setQueryData<Alarm[]>(["alarms"], (prev = []) =>
        prev.map((a) =>
          a.id === alarmId ? { ...a, acknowledged: true } : a
        )
      );

      return { previousAlarms };
    },

    // Rollback ved fejl
    onError: (err, alarmId, context) => {
      if (context?.previousAlarms) {
        queryClient.setQueryData(["alarms"], context.previousAlarms);
      }
    },

    // Synkronisér med server uanset outcome
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["alarms"] });
    },
  });

  return (
    <div className={`alarm-row ${alarm.acknowledged ? "acked" : ""}`}>
      <span>{alarm.message}</span>
      {!alarm.acknowledged && (
        <button
          onClick={() => acknowledgeMutation.mutate(alarm.id)}
          disabled={acknowledgeMutation.isPending}
        >
          {acknowledgeMutation.isPending ? "..." : "Kvittér"}
        </button>
      )}
    </div>
  );
}

Infinite query: Aflæsningshistorik

// ui/src/pages/portal/ReadingHistory.tsx

import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";

export function ReadingHistory({ installationId }: { installationId: string }) {
  const { ref, inView } = useInView();

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["readings", installationId],
    queryFn: ({ pageParam }) =>
      api.readings.list({
        installation_id: installationId,
        before: pageParam as string | undefined,
        limit: 50,
      }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
    staleTime: 60_000,
  });

  // Auto-load næste side ved scroll til bund
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  const allReadings = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <div className="reading-list">
      {allReadings.map((reading) => (
        <ReadingRow key={reading.timestamp} reading={reading} />
      ))}
      <div ref={ref} className="scroll-sentinel">
        {isFetchingNextPage && <Spinner />}
      </div>
    </div>
  );
}

Query prefetching og Server-Side

// ui/src/pages/portal/PropertyDetail.tsx

import { useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";

function UnitLink({ unit }: { unit: Unit }) {
  const queryClient = useQueryClient();

  return (
    <Link
      to={`/portal/units/${unit.id}`}
      // Prefetch unit data ved hover — bruger ser aldrig loading-state
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: ["unit", unit.id],
          queryFn: () => api.portal.getUnit(unit.id),
          staleTime: 30_000,
        });
      }}
    >
      {unit.address}
    </Link>
  );
}

Fejlhåndtering med ErrorBoundary

// ui/src/components/QueryErrorBoundary.tsx

import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";

export function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ error, resetErrorBoundary }) => (
            <div className="error-banner">
              <p>Noget gik galt: {(error as Error).message}</p>
              <button onClick={resetErrorBoundary}>Prøv igen</button>
            </div>
          )}
        >
          {children}
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

// Brug i App.tsx:
// <QueryErrorBoundary>
//   <PortalDashboard />
// </QueryErrorBoundary>

QueryClient konfiguration

// ui/src/main.tsx

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,          // 30 sek default
      gcTime: 5 * 60 * 1000,     // 5 min garbage collection
      retry: 2,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000),
      refetchOnWindowFocus: true, // Hent frisk data ved tab-focus
      refetchOnReconnect: true,   // Hent frisk data ved netværks-reconnect
    },
    mutations: {
      retry: 1,
    },
  },
});

Konklusion

TanStack Query kombinerer polling (refetchInterval: 30_000) og SSE (via useEffect + EventSource) — polling for stabilitet, SSE for realtid. Optimistiske mutations giver øjeblikkelig UI-feedback med automatisk rollback ved fejl. Infinite query med IntersectionObserver giver native-feeling scroll-paginering. QueryClient i main.tsx konfigurerer global retry og staletime for hele portalen.

Se TanStack Query guide eller React PWA guide.