TanStack Router 10 ⚛️ Pending States — Handling loading UI
The previous chapter covered the data loaders that fetch the route data before the component renders. This chapter covers the UI that appears while the loader is in progress. In the TanStack Router, the pending state is managed at the route level, not the component level. The route object has a pendingComponent property that renders while the loader is unresolved. The pendingMs and pendingMinMs options control the timing of the pending UI to prevent the flicker. The defaultPendingComponent and defaultPendingMs in the router options set the global defaults. The critical trap is the missing pendingComponent. Without it, the router waits for the loader silently, and the user sees no feedback. The navigation appears frozen, and the user clicks again, which triggers another navigation and makes the situation worse. The pending UI is the mandatory feedback for the asynchronous route.
Key point: The pendingComponent is the route-level loading UI. The pendingMs is the delay before the pending UI appears. The pendingMinMs is the minimum time the pending UI stays. The global defaults are set with the defaultPendingComponent and defaultPendingMs in the createRouter options. The pendingMinMs is the anti-flicker mechanism: it prevents the pending UI from flashing for a fast loader. The router.isLoading is the programmatic access to the pending state.
Why pending states require a specific mental model
The silent wait problem. Without a pendingComponent, the router waits for the loader before rendering the component. The current page remains on screen. The user clicks a link, nothing happens visually, and the user assumes the click did not register. The pending UI is the feedback that tells the user “the navigation is in progress.” The missing pending component is the most common cause of the “the app is broken” perception during slow network conditions.
The flicker problem. If the pending component appears immediately for a loader that resolves in 50 milliseconds, the user sees a flash of the loading UI. The flash is more distracting than the brief wait. The pendingMs is the delay before the pending UI appears. The loader that resolves within the delay shows no pending UI. The pendingMinMs is the minimum time the pending UI stays once it appears. The two options together prevent the flicker.
The route-level versus component-level problem. The pending state is a route-level concern. The loader is the route’s data fetch. The pending UI is the route’s loading state. The component does not have the isLoading state because the component does not render until the data is ready. The pending UI is the parent route’s responsibility or the route’s pendingComponent. The component-level useState is the wrong tool for the route-level data.
The parallel loading problem. The parent and child loaders run in parallel. The parent’s pendingComponent shows while the parent’s loader is unresolved. The child’s pendingComponent shows while the child’s loader is unresolved. The nested pending components can stack, which is the desired behavior: the parent’s skeleton shows first, then the child’s.
The pendingMinMs problem. The pendingMinMs is the minimum time the pending UI is shown. The loader that resolves in 600 milliseconds with a pendingMs of 500 and a pendingMinMs of 300 shows the pending UI for the 100 milliseconds until the loader resolves, but the pendingMinMs forces the UI to stay for the full 300 milliseconds. The result is the 300-millisecond pending UI, which is the minimum acceptable visibility.
The trade-off. The pending UI is the mandatory feedback. The pendingMs and pendingMinMs are the anti-flicker tuning. The default values are 1000 milliseconds and 500 milliseconds, which is the reasonable starting point. The aggressive pending UI (low pendingMs) is the better choice for the slow networks. The lazy pending UI (high pendingMs) is the better choice for the fast networks.
a. The route-level pendingComponent
The pendingComponent is the route property that renders while the loader is in progress.
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts')({
loader: async () => {
const res = await fetch('/api/posts')
return res.json()
},
component: PostsList,
pendingComponent: PostsPending,
})
function PostsPending() {
return (
<div className="skeleton">
<div className="skeleton-header" />
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
)
}
function PostsList() {
const posts = Route.useLoaderData()
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
The PostsPending renders while the loader is unresolved. The PostsList renders when the loader resolves. The router switches between the two based on the loader state.
The pendingComponent can access the route params and search params.
function PostsPending() {
const { page } = Route.useSearch()
return <p>Loading page {page}...</p>
}
The pending component is a full route component. It has access to the same hooks as the regular component.
b. The pendingMs and pendingMinMs timing
The pendingMs and pendingMinMs options control the timing of the pending UI.
export const Route = createFileRoute('/posts')({
loader: ...,
component: PostsList,
pendingComponent: PostsPending,
pendingMs: 1000,
pendingMinMs: 500,
})
The pendingMs: 1000 means the pending UI appears after 1 second of loading. The loader that resolves in 500 milliseconds shows no pending UI. The loader that takes 2 seconds shows the pending UI for 1 second (2 seconds total minus the 1 second delay).
The pendingMinMs: 500 means the pending UI stays for at least 500 milliseconds once it appears. The loader that resolves in 1.1 seconds shows the pending UI for 0.1 seconds (the remaining 0.1 seconds), but the pendingMinMs forces the UI to stay for the full 500 milliseconds.
The combination prevents the flash. The fast loader shows no pending UI. The slow loader shows the pending UI for a visible duration.
The global defaults are set in the router options.
const router = createRouter({
routeTree,
defaultPendingComponent: GlobalPending,
defaultPendingMs: 1000,
defaultPendingMinMs: 500,
})
The defaultPendingComponent is the fallback for the routes that do not have their own pendingComponent. The defaultPendingMs and defaultPendingMinMs are the defaults for all routes.
c. The router.isLoading and the programmatic access
The router.isLoading property is the boolean that indicates whether any loader is in progress.
import { useRouter } from '@tanstack/react-router'
function GlobalLoadingIndicator() {
const router = useRouter()
return (
<>
{router.isLoading && <div className="top-bar-loading" />}
</>
)
}
The router.isLoading is true while at least one loader is unresolved. The indicator is the thin progress bar at the top of the page, which is the common pattern.
The useRouterState hook is the alternative for the specific router state slices.
import { useRouterState } from '@tanstack/react-router'
function GlobalLoadingIndicator() {
const isLoading = useRouterState({ select: (s) => s.isLoading })
return isLoading ? <div className="top-bar-loading" /> : null
}
The select function narrows the state to the isLoading boolean. The component re-renders only when that slice changes, not on every router state change.
The useRouter is the simpler access, but it re-renders on every router state change. The useRouterState with the select is the more efficient access.
Complete Example Session
# ============================================
# PART 1: BASIC PENDING COMPONENT
# ============================================
pendingComponent: () => <p>Loading...</p>
# ============================================
# PART 2: SKELETON UI
# ============================================
function PostsPending() {
return <div className="skeleton"><div /><div /><div /></div>
}
# ============================================
# PART 3: PENDING WITH PARAMS
# ============================================
function PostsPending() {
const { page } = Route.useSearch()
return <p>Loading page {page}...</p>
}
# ============================================
# PART 4: TIMING
# ============================================
pendingMs: 1000,
pendingMinMs: 500,
# ============================================
# PART 5: GLOBAL DEFAULTS
# ============================================
const router = createRouter({
routeTree,
defaultPendingComponent: GlobalPending,
defaultPendingMs: 1000,
defaultPendingMinMs: 500,
})
# ============================================
# PART 6: GLOBAL LOADING INDICATOR
# ============================================
const isLoading = useRouterState({ select: (s) => s.isLoading })
return isLoading ? <Bar /> : null
# ============================================
# PART 7: NESTED PENDING
# ============================================
// Parent pending shows first
// Child pending shows inside parent's Outlet
# ============================================
# PART 8: PREFETCH AND PENDING
# ============================================
// Prefetching with router.preloadRoute
// Reduces the pending time
# ============================================
# PART 9: VERIFY
# ============================================
# Slow loader → pending UI shows after 1s
# Fast loader → no pending UI
# Top bar shows router.isLoading
The nine parts trace the pending state: basic, skeleton, params, timing, defaults, indicator, nested, prefetch, and verification.
Quick Reference
Route Options
| Option | Purpose |
|---|---|
pendingComponent | Loading UI |
pendingMs | Delay before showing |
pendingMinMs | Min display duration |
Router Options
| Option | Purpose |
|---|---|
defaultPendingComponent | Global default |
defaultPendingMs | Global delay |
defaultPendingMinMs | Global min duration |
State Access
| Method | Purpose |
|---|---|
router.isLoading | Boolean |
useRouterState({ select }) | Sliced state |
useRouter() | Router instance |
Timing Examples
| pendingMs | pendingMinMs | Effect |
|---|---|---|
| 1000 | 500 | Show after 1s, stay 0.5s |
| 0 | 0 | Show immediately |
| 500 | 200 | Show after 0.5s, stay 0.2s |
Best Practices
✅ Do This:
pendingComponent: PostsSkeleton
<!-- Provide the loading UI --> # ✅
pendingMs: 1000, pendingMinMs: 500
<!-- Prevent the flicker --> # ✅
const isLoading = useRouterState({ select: (s) => s.isLoading })
<!-- Use the sliced state for the indicator --> # ✅
❌ Don’t Do This:
# Omit the pending component
<!-- The user sees the frozen page --> # ❌
pendingMs: 0, pendingMinMs: 0
<!-- The flash on every navigation --> # ❌
useRouter() for the isLoading
<!-- Re-renders on every state change --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No loading feedback | Missing pendingComponent | Add the UI |
| Pending flicker | No pendingMs | Add the delay |
| Pending stays too long | No pendingMinMs | Add the min duration |
| Indicator flickers | router.isLoading on every change | Use useRouterState |
| Nested pending stack | Both parent and child pending | Verify the layout |
Real-World Examples
1. Basic Pending
pendingComponent: () => <p>Loading...</p>
2. Skeleton
pendingComponent: PostsSkeleton
3. Pending with Params
function Pending() { const { page } = Route.useSearch(); return <p>Loading {page}</p>; }
4. Timing
pendingMs: 1000, pendingMinMs: 500
5. Global Pending
defaultPendingComponent: GlobalPending
6. Global Indicator
const isLoading = useRouterState({ select: (s) => s.isLoading })
7. Nested Pending
parent: pendingComponent, children: pendingComponent
8. Prefetch
router.preloadRoute({ to: '/posts', search: { page: 2 } })
9. Pending Min
pendingMinMs: 500
10. Custom Loading
pendingComponent: () => <Spinner size="lg" />
Visual
Pending Flow
┌──────────────────────────────────────────────┐
│ Click link to /posts │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader starts │
│ → Pending UI not shown yet (pendingMs) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ After pendingMs (1s) │
│ → Pending UI shows │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader resolves │
│ → Component renders │
└──────────────────────────────────────────────┘
Timing Options
┌──────────────────────────────────────────────┐
│ Loader: 500ms │
│ pendingMs: 1000 │
│ → No pending UI (loader < pendingMs) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader: 1500ms │
│ pendingMs: 1000, pendingMinMs: 500 │
│ → Pending UI for 1000ms (1500-1000+500) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Route pending UI | pendingComponent |
| Delay | pendingMs |
| Min duration | pendingMinMs |
| Global defaults | defaultPendingComponent |
| Global delay | defaultPendingMs |
| Boolean access | router.isLoading |
| Sliced access | useRouterState({ select }) |
Key takeaways:
- The
pendingComponentis the mandatory feedback. Without it, the user sees the frozen page and assumes the navigation did not register. The route-level pending UI is the correct tool for the loader loading state. - The
pendingMsandpendingMinMsprevent the flicker. ThependingMsis the delay before the pending UI appears. ThependingMinMsis the minimum time the pending UI stays. The two together produce the smooth transitions. - The pending UI is route-level, not component-level. The component does not render until the loader resolves. The
pendingComponentis the route’s loading UI. The component’suseStateis the wrong tool. - The
router.isLoadingis the programmatic access. The global loading indicator uses theuseRouterStatewith theselectto re-render only on the relevant slice change. TheuseRouterre-renders on every state change. - The global defaults are the fallback. The
defaultPendingComponentand thedefaultPendingMsare the router-level defaults. The route-level options override them.
Remember: The pending state is the route-level UI. The pendingComponent is the UI. The pendingMs is the delay. The pendingMinMs is the min duration. The router.isLoading is the boolean. The useRouterState is the sliced. The exam tests the pending configuration and the anti-flicker timing.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!