Skip to main content

Data loading

Data fetching in @camunda/orchestration-cluster-webapp uses TanStack Router for route-level loaders and TanStack Query for server-state caching. This page defines the order of preference for data-loading patterns in the app. For background, see the TanStack Router data-loading guide.

Stack​

  • TanStack Router — route loaders, link preloading, suspense integration.
  • TanStack Query — server-state cache, suspense queries, mutations.

Order of preference​

The tiers below are listed in descending order of preference. Pick the highest option that fits the page; drop a tier only when a constraint forces it.

1. Route loader + suspense queries​

The route loader prefetches via queryClient.ensureQueryData; the component reads the same options via useSuspenseQuery.

// src/routes/_auth/profile.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { currentUserQueryOptions } from "#/shared/http/queries";
import { MyUserComponent } from "#/operate/components/MyUserComponent";

export const Route = createFileRoute("/_auth/profile")({
loader: ({ context: { queryClient } }) => {
// route will only render once this promise resolves, so we can await or return it
return queryClient.ensureQueryData(currentUserQueryOptions);
},
component: Profile,
});

function Profile() {
const { data: user } = useSuspenseQuery(currentUserQueryOptions);
return <MyUserComponent user={user} />;
}

queryOptions constants live in #/shared/http/queries.ts (or co-located with the owning pod). Loader and component import the same reference so the cache key and fetcher stay in sync.

2. Route loader + pendingComponent​

Same shape as Tier 1, plus pendingComponent so the router falls back to a placeholder instead of freezing the previous page when the loader takes too long.

// src/routes/_auth/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { dashboardQueryOptions } from "#/shared/http/queries";
import { DashboardSkeleton } from "#/operate/components/DashboardSkeleton";
import { MyDashboard } from "#/operate/components/MyDashboard";

export const Route = createFileRoute("/_auth/dashboard")({
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(dashboardQueryOptions);
},
pendingComponent: DashboardSkeleton,
component: Dashboard,
});

function Dashboard() {
const { data } = useSuspenseQuery(dashboardQueryOptions);
return <MyDashboard data={data} />;
}

Tune pendingMs (default 1000ms) and pendingMinMs per route, or set defaultPendingComponent and defaultPendingMs on createRouter to apply globally.

3. Route loader + streamed promises + granular skeletons​

The loader awaits the fast slice via ensureQueryData and fires-and-forgets the slow one via prefetchQuery. The slow slice is wrapped in its own <Suspense> so the fast content paints immediately and a skeleton stands in for the slow part until it resolves.

// src/routes/_auth/dashboard.tsx
import { Suspense } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
dashboardSummaryQueryOptions,
dashboardMetricsQueryOptions,
} from "#/shared/http/queries";
import { MetricsPanelSkeleton } from "#/operate/components/MetricsPanelSkeleton";
import { Metrics } from "#/operate/components/Metrics";

export const Route = createFileRoute("/_auth/dashboard")({
loader: async ({ context: { queryClient } }) => {
// await the fast query
await queryClient.prefetchQuery(dashboardSummaryQueryOptions);

// fire-and-forget the slow query
queryClient.ensureQueryData(dashboardMetricsQueryOptions);
},
component: Dashboard,
});

function Dashboard() {
const { data: summary } = useSuspenseQuery(dashboardSummaryQueryOptions);
return (
<main>
<h1>{summary.title}</h1>
<Suspense fallback={<MetricsPanelSkeleton />}>
{/*.Consumes dashboardMetricsQueryOptions */}
<Metrics />
</Suspense>
</main>
);
}

Pick this over Tier 2 only when the slow slice is isolated and the rest of the page renders fast — otherwise Tier 2 is simpler.

Error handling​

The default is errorComponent on the route. The queryFn throws on failure, so Query rejects, the loader rejects, and the router renders errorComponent in place of the route component.

// src/routes/_auth/profile.tsx
import {
createFileRoute,
type ErrorComponentProps,
} from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { currentUserQueryOptions } from "#/shared/http/queries";

export const Route = createFileRoute("/_auth/profile")({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(currentUserQueryOptions),
errorComponent: ProfileError,
component: Profile,
});

function ProfileError({ error, reset }: ErrorComponentProps) {
return (
<main>
<h1>Could not load profile</h1>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</main>
);
}

function Profile() {
const { data: user } = useSuspenseQuery(currentUserQueryOptions);
return <h1>Hello, {user.displayName}</h1>;
}

Set defaultErrorComponent on createRouter for a global fallback. Reach for a component-level <ErrorBoundary> only when the rest of the page should keep rendering. 401s are handled centrally by the request() wrapper (cache clear + login redirect). Query functions normalize 403 responses to ForbiddenError, so route error boundaries can render a forbidden state consistently. errorComponent covers everything else.

References​