M-Bus Gateway
← Tilbage til blog
· TanStack Query· React Query· React· TypeScript· datahentning· caching· polling· frontend· state management

TanStack Query i React — caching, polling og optimistiske opdateringer

TanStack Query (React Query) til datahentning: queryClient, useQuery, useMutation, polling, stale-while-revalidate, optimistiske opdateringer og invalidering.

Af M-Bus Gateway

M-Bus Gateway portalen bruger TanStack Query til al server state — ingen Redux, ingen manuel fetch-logik. Her er mønstrene der giver hurtig og konsistent UI.


QueryClient: Global konfiguration

// ui/src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Cached data er "frisk" i 60 sekunder — ingen refetch i det vindue:
      staleTime: 60 * 1000,
      // Behold cache i 5 minutter efter komponent unmounts:
      gcTime: 5 * 60 * 1000,
      // Retry 3× ved fejl med eksponentiel backoff:
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
      // Refetch ved vinduefokus (bruger er væk og vender tilbage):
      refetchOnWindowFocus: true,
    },
    mutations: {
      retry: 0,  // Mutationer retry aldrig automatisk
    },
  },
});

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

useQuery: Hent data med automatisk caching

// ui/src/pages/portal/Properties.tsx
import { useQuery } from "@tanstack/react-query";
import { portalProperties } from "@/api";

// Query key: Array — bruges til cache-identifikation + invalidering:
const PROPERTIES_KEY = ["properties"] as const;

export function PropertiesPage() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: PROPERTIES_KEY,
    queryFn: () => portalProperties.list(),
    // Override global staleTime for denne query:
    staleTime: 30 * 1000,
  });

  if (isLoading) return <Spinner />;
  if (isError) return <ErrorBanner message={error.message} />;

  return (
    <div>
      {data.map((property) => (
        <PropertyCard key={property.id} property={property} />
      ))}
    </div>
  );
}


// Parameteriseret query — nyt cache-entry pr. propertyId:
function usePropertyDetail(propertyId: string) {
  return useQuery({
    queryKey: ["properties", propertyId],
    queryFn: () => portalProperties.get(propertyId),
    enabled: !!propertyId,  // Kør kun hvis propertyId er defineret
  });
}

Polling: Live gateway-status

// Gateway-status refreshes hvert 30 sek — gateway sender heartbeat hvert 5 min:
function useGatewayStatus(gatewayId: string) {
  return useQuery({
    queryKey: ["gateways", gatewayId, "status"],
    queryFn: () => portalGateways.getStatus(gatewayId),
    // Poll hvert 30 sekunder mens komponenten er mounted:
    refetchInterval: 30 * 1000,
    // Stop polling når vindue ikke er i fokus:
    refetchIntervalInBackground: false,
  });
}

// Conditional polling: Kun poll mens gateway er offline:
function useGatewayStatusUntilOnline(gatewayId: string) {
  return useQuery({
    queryKey: ["gateways", gatewayId, "status"],
    queryFn: () => portalGateways.getStatus(gatewayId),
    refetchInterval: (query) => {
      // Stop polling når gateway er online:
      if (query.state.data?.online) return false;
      return 10 * 1000;  // Poll hvert 10 sek ellers
    },
  });
}

useMutation + automatisk invalidering

// Opret ejendom og invalidér properties-listen automatisk:
import { useMutation, useQueryClient } from "@tanstack/react-query";

function useCreateProperty() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: PropertyIn) => portalProperties.create(data),

    onSuccess: (newProperty) => {
      // Invalidér listen → næste rendering refetches fra server:
      queryClient.invalidateQueries({ queryKey: ["properties"] });

      // Eller: Indsæt direkte i cache uden ekstra request:
      queryClient.setQueryData(
        ["properties", newProperty.id],
        newProperty,
      );
    },

    onError: (error) => {
      toast.error(`Oprettelse fejlede: ${error.message}`);
    },
  });
}

// I komponent:
function CreatePropertyForm() {
  const createMutation = useCreateProperty();

  const handleSubmit = (data: PropertyIn) => {
    createMutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... */}
      <button disabled={createMutation.isPending}>
        {createMutation.isPending ? "Opretter..." : "Opret ejendom"}
      </button>
    </form>
  );
}

Optimistiske opdateringer

// Opdatér UI øjeblikkeligt — rull tilbage ved fejl:

function useDeleteMeter() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (meterId: string) => portalMeters.delete(meterId),

    onMutate: async (meterId) => {
      // Annullér igangværende fetches (undgå race condition):
      await queryClient.cancelQueries({ queryKey: ["meters"] });

      // Gem nuværende state til rollback:
      const previousMeters = queryClient.getQueryData<Meter[]>(["meters"]);

      // Optimistisk update — fjern fra cache øjeblikkeligt:
      queryClient.setQueryData<Meter[]>(["meters"], (old) =>
        old?.filter((m) => m.id !== meterId) ?? []
      );

      return { previousMeters };
    },

    onError: (_err, _meterId, context) => {
      // Rollback til forrige state ved fejl:
      if (context?.previousMeters) {
        queryClient.setQueryData(["meters"], context.previousMeters);
      }
      toast.error("Sletning fejlede — prøv igen");
    },

    onSettled: () => {
      // Altid invalidér efter mutation (success eller fejl):
      queryClient.invalidateQueries({ queryKey: ["meters"] });
    },
  });
}

Infinite queries: Pagineret liste

// Aflæsningshistorik — load more ved scroll:
import { useInfiniteQuery } from "@tanstack/react-query";

function useReadingsInfinite(installationId: string) {
  return useInfiniteQuery({
    queryKey: ["readings", installationId],
    queryFn: ({ pageParam = 1 }) =>
      portalReadings.list({ installationId, page: pageParam, pageSize: 50 }),
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.page + 1 : undefined,
    initialPageParam: 1,
  });
}

function ReadingsList({ installationId }: { installationId: string }) {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useReadingsInfinite(installationId);

  return (
    <div>
      {data?.pages.flatMap((page) => page.items).map((reading) => (
        <ReadingRow key={reading.id} reading={reading} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Henter..." : "Hent flere"}
        </button>
      )}
    </div>
  );
}

Konklusion

TanStack Query eliminerer behovet for Redux/Zustand til server state. staleTime styrer cache-friskhed — gcTime styrer hukommelsesrydning. Polling med refetchInterval giver live-opdateringer uden WebSocket-kompleksitet. Optimistiske opdateringer sikrer instant UI-respons med automatisk rollback ved fejl. Cache-invalidering via invalidateQueries holder data konsistent på tværs af sider.

Se TypeScript API-typer guide eller React PWA offline guide.