· 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.