DEV Community

pjdev2d
pjdev2d

Posted on

page

# 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:

Enter fullscreen mode Exit fullscreen mode


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”.

Enter fullscreen mode Exit fullscreen mode


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={...}>`.

Enter fullscreen mode Exit fullscreen mode


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)