# Dashboard Route: Suspense + Skeleton Flow
This note explains how **React Suspense** works with **TanStack Query v5** when using `useSuspenseQuery()`, and how to implement **page-level** and **per-widget** skeleton fallbacks.
## Mental model
When you use:
ts
const { data } = useSuspenseQuery(dashboardQueries.alerts());
If the query data is not available yet, TanStack Query **suspends** the component render by throwing a Promise under the hood.
React then:
1. Finds the **nearest parent** `<Suspense fallback={...}>`
2. Renders the `fallback` UI instead of the suspended subtree
3. When the Promise resolves (data is fetched and cached), React retries rendering
4. Now the hook returns data and the real UI renders
**Important:** The `<Suspense>` boundary does not check `isLoading` flags. It automatically shows `fallback` whenever a child suspends.
## Router loading vs data loading
You may see two kinds of “loading”:
### 1) Route module loading (code-splitting)
In `src/router/index.tsx`, routes are defined using `lazy: async () => import(...)`.
That covers loading the **route file bundle** (JS chunk). While that import is pending, `RouterProvider` can show its `fallbackElement`.
### 2) Data loading (TanStack Query)
Inside a route component (like Dashboard), `useSuspenseQuery()` suspends while it fetches data.
This is handled by `<Suspense>` boundaries you place in your component tree.
## Where to place `<Suspense>`
### Option A: Page-level fallback
Wrap the full dashboard page content in a single `<Suspense>`. Good when you want a “whole page skeleton”.
tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
}>
);
}
### Option B: Per-widget fallback (recommended for dashboards)
Split the dashboard into sections (Stats / Revenue / Alerts). Wrap each section with its own `<Suspense fallback={...}>`.
tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
}>
<Suspense fallback={<RevenueSkeleton />}>
<DashboardRevenueSection />
</Suspense>
<Suspense fallback={<AlertsSkeleton />}>
<DashboardAlertsSection />
</Suspense>
</div>
);
}
Each section can safely use `useSuspenseQuery()` internally:
ts
const { data: statCards } = useSuspenseQuery(dashboardQueries.stats());
React will show the section fallback only while that specific query is pending.
## Alternative (single-file) whole-page Suspense wrapper
If you don’t want to wrap `<Outlet />` and you don’t want to split widgets into separate files, you can still keep a clean whole-page skeleton by using a **wrapper component in the same file**:
tsx
import { Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { dashboardQueries } from "@/services/dashboard";
import DashboardSkeleton from "@/components/base/YourSkeleton";
export default function DashboardPage() {
return (
}>
);
}
function DashboardInner() {
const { data: statCards } = useSuspenseQuery(dashboardQueries.stats());
const { data: revenueData } = useSuspenseQuery(dashboardQueries.revenue());
const { data: alerts } = useSuspenseQuery(dashboardQueries.alerts());
return
{/* current dashboard JSX */};}
Why this works:
- `DashboardInner` can suspend during render (because of `useSuspenseQuery`)
- The **parent** `<Suspense>` in `DashboardPage` catches the suspension and shows the skeleton
- Putting `<Suspense>` **inside** the same component that calls `useSuspenseQuery` does not help, because the component suspends before it can return JSX
## Why route `lazy` exists (and why it’s still useful with Suspense)
In `src/router/index.tsx`, routes use:
ts
lazy: async () => {
const module = await import("../routes/dashboard");
return { Component: module.default };
}
This is **route-level code splitting**:
- The JS code for a route is not downloaded until the user navigates to that route.
- This reduces initial bundle size and improves first load performance.
How it relates to Suspense/data loading:
- `lazy` covers **loading the route module (code)**.
- `useSuspenseQuery()` covers **loading the route data**.
- `RouterProvider` shows its `fallbackElement` while the route module is loading.
- `<Suspense fallback>` shows skeletons while the route data is loading.
## What about `isLoading` / `isFetching`?
- With `useQuery(...)` you can render skeletons using:
- `isLoading` (first load)
- `isFetching` (any fetch)
- `isRefetching` (fetching again after data exists)
- With `useSuspenseQuery(...)`:
- initial loading UI comes from `<Suspense fallback={...}>`
- you can still read `isFetching` / `isRefetching` to show a “refreshing” indicator while keeping existing data on screen
## Recommended pattern for this repo
- Keep app shell (Sidebar/Topbar) static
- Prefer **per-widget Suspense** boundaries inside dashboard pages for the best UX
- Put all data logic in `src/services/dashboard/*` (queries/mutations/services/mappers/mocks/keys)
Top comments (0)