· React· Error Boundary· Suspense· TanStack Query· TypeScript· dashboard· IoT· Sentry
React Error Boundaries og Suspense — robuste dashboards
React Error Boundaries og Suspense til IoT-dashboards: fejlhåndtering, loading states, TanStack Query integration, Sentry error reporting og graceful degradation.
Af M-Bus Gateway
IoT-dashboards viser data fra mange endpoints parallelt. Error Boundaries og Suspense sikrer at én fejl ikke crasher hele siden.
Error Boundary implementation
// ui/src/components/ErrorBoundary.tsx
import React, { Component, ReactNode, ErrorInfo } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ErrorBoundary caught:", error, info.componentStack);
this.props.onError?.(error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="error-card">
<h3>Noget gik galt</h3>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Prøv igen
</button>
</div>
);
}
return this.props.children;
}
}
// Functional wrapper med custom hook:
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode,
) {
return function WrappedComponent(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
};
}
TanStack Query error handling
// ui/src/hooks/usePortfolioKPI.ts
import { useQuery } from "@tanstack/react-query";
import { portalPortfolio } from "../api";
export function usePortfolioKPI() {
return useQuery({
queryKey: ["portfolio", "kpi"],
queryFn: () => portalPortfolio.kpi(),
retry: (failureCount, error) => {
// Retry aldrig ved 401/403/404 — kun ved netværksfejl
if (error instanceof ApiError && error.status < 500) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
staleTime: 5 * 60 * 1000, // 5 minutter
refetchInterval: 30_000, // Opdatér hvert 30 sekund
refetchIntervalInBackground: false, // Stop ved fane i baggrunden
});
}
// Komponent der bruger hook:
export function KPISection() {
const { data, isLoading, isError, error, refetch } = usePortfolioKPI();
if (isLoading) return <KPISkeleton />;
if (isError) {
return (
<div className="error-banner">
<span>KPI-data utilgængelig</span>
<button onClick={() => refetch()}>Genindlæs</button>
</div>
);
}
return (
<div className="kpi-grid">
<KPICard label="Ejendomme" value={data.property_count} />
<KPICard label="Enheder" value={data.unit_count} />
<KPICard label="Aktive gateways" value={data.active_gateways} />
</div>
);
}
Skeleton loading states
// ui/src/components/skeletons/KPISkeleton.tsx
export function KPISkeleton() {
return (
<div className="kpi-grid" aria-busy="true" aria-label="Indlæser KPI-data">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="kpi-card skeleton">
<div className="skeleton-line short" />
<div className="skeleton-line long" />
</div>
))}
</div>
);
}
export function ChartSkeleton({ height = 300 }: { height?: number }) {
return (
<div
className="chart-card skeleton"
style={{ height }}
aria-busy="true"
aria-label="Indlæser graf"
/>
);
}
/* ui/src/index.css */
.skeleton {
background: linear-gradient(90deg, #1e293b 25%, #2d3748 50%, #1e293b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-line {
height: 12px;
border-radius: 4px;
margin: 8px 0;
background: rgba(255,255,255,0.1);
}
.skeleton-line.short { width: 40%; }
.skeleton-line.long { width: 80%; }
Dashboard med parallelle queries
// ui/src/pages/portal/PortalDashboard.tsx
import { ErrorBoundary } from "../components/ErrorBoundary";
import { Suspense } from "react";
// Wrapper: Suspense + ErrorBoundary pr. sektion
function DashboardSection({
children,
title,
}: {
children: React.ReactNode;
title: string;
}) {
return (
<section>
<h2 className="section-title">{title}</h2>
<ErrorBoundary
fallback={
<div className="error-banner">
Denne sektion er midlertidigt utilgængelig
</div>
}
>
<Suspense fallback={<ChartSkeleton />}>
{children}
</Suspense>
</ErrorBoundary>
</section>
);
}
export function PortalDashboard() {
return (
<div className="dashboard">
<DashboardSection title="Portefølje KPI">
<KPISection />
</DashboardSection>
<DashboardSection title="Varmeafregning status">
<SettlementFunnelChart />
</DashboardSection>
<DashboardSection title="Gateway status">
<GatewayHealthGrid />
</DashboardSection>
{/* Én sektion kan fejle uden at crashe de andre */}
</div>
);
}
Sentry fejlrapportering
// ui/src/main.tsx
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: true, // GDPR: Mask lejerdata
blockAllMedia: true,
}),
],
tracesSampleRate: 0.1, // 10% af requests
replaysOnErrorSampleRate: 1.0, // 100% ved fejl
beforeSend(event) {
// Fjern sensitive data inden sending:
if (event.user) {
delete event.user.email;
delete event.user.username;
}
return event;
},
});
// ErrorBoundary med Sentry:
const SentryErrorBoundary = Sentry.withErrorBoundary(MyComponent, {
fallback: <ErrorFallback />,
showDialog: false,
});
Global error handler
// ui/src/api.ts — API error handling
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
public code?: string,
) {
super(detail);
this.name = "ApiError";
}
}
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
...options?.headers,
},
});
if (!response.ok) {
const body = await response.json().catch(() => ({ detail: "Ukendt fejl" }));
throw new ApiError(response.status, body.detail, body.code);
}
return response.json();
}
Konklusion
Error Boundaries isolerer fejl til den berørte sektion — resten af dashboardet forbliver funktionelt. Skeleton loading states giver brugere øjeblikkelig feedback. TanStack Query's retry callback skelner mellem netværksfejl (retry) og client-fejl 401/403/404 (ingen retry). Sentry med maskAllText sikrer GDPR-compliant fejlrapportering.