M-Bus Gateway
← Tilbage til blog
· 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.

Se TanStack Query guide eller React PWA guide.