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), so errorComponent
covers everything else.