| | |

TanStack Router 10 ⚛️ Pending States — Handling loading UI

⏱️ Estimated read: 9 min | |

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

OptionPurpose
pendingComponentLoading UI
pendingMsDelay before showing
pendingMinMsMin display duration

Router Options

OptionPurpose
defaultPendingComponentGlobal default
defaultPendingMsGlobal delay
defaultPendingMinMsGlobal min duration

State Access

MethodPurpose
router.isLoadingBoolean
useRouterState({ select })Sliced state
useRouter()Router instance

Timing Examples

pendingMspendingMinMsEffect
1000500Show after 1s, stay 0.5s
00Show immediately
500200Show 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

PitfallWhy It HappensFix
No loading feedbackMissing pendingComponentAdd the UI
Pending flickerNo pendingMsAdd the delay
Pending stays too longNo pendingMinMsAdd the min duration
Indicator flickersrouter.isLoading on every changeUse useRouterState
Nested pending stackBoth parent and child pendingVerify 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

ItemValue
Route pending UIpendingComponent
DelaypendingMs
Min durationpendingMinMs
Global defaultsdefaultPendingComponent
Global delaydefaultPendingMs
Boolean accessrouter.isLoading
Sliced accessuseRouterState({ select })

Key takeaways:

  • The pendingComponent is 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 pendingMs and pendingMinMs prevent the flicker. The pendingMs is the delay before the pending UI appears. The pendingMinMs is 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 pendingComponent is the route’s loading UI. The component’s useState is the wrong tool.
  • The router.isLoading is the programmatic access. The global loading indicator uses the useRouterState with the select to re-render only on the relevant slice change. The useRouter re-renders on every state change.
  • The global defaults are the fallback. The defaultPendingComponent and the defaultPendingMs are 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.

Was this helpful?


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!