Documentation

Routing

Loading States

Show a top progress bar for route navigation, section skeletons for loading content, and button spinners for actions in VitNode.

VitNode gives every loading moment one indicator: a top progress bar for route navigation, a skeleton for a section that is still loading, and a spinner inside the button that started an action.

During a client-side navigation, a slim bar moves across the top of the viewport while the current page, header, and sidebar stay on screen and usable. NavigationProgress from @vitnode/core/tanstack/pending draws it, and VitNodeRootProviders already mounts it, so an app gets it without extra code.

The bar follows the router's own lifecycle:

  • It appears only when a navigation lasts longer than 150 ms, so fast navigations never flash.
  • It finishes when the router commits the destination, including an error page, a not-found page, or the target of a redirect.
  • A newer navigation that replaces a pending one keeps the same bar moving instead of restarting it.
  • Preloading on hover, router.invalidate(), and TanStack Query refetches never start it.
  • With prefers-reduced-motion, it fades in and out at full width instead of moving.

To keep the current page visible, the router never swaps it for a pending component during a navigation:

apps/web/src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { RoutePendingSpinner } from '@vitnode/core/tanstack/pending'

export function getRouter() {
  return createTanStackRouter({
    routeTree,
    defaultPendingComponent: RoutePendingSpinner,
    defaultPendingMs: Infinity,
  })
}

defaultPendingComponent still matters: it is the Suspense fallback of a route that has no pendingComponent of its own.

Leave pendingMs alone

Setting pendingMs on a route brings back the old behavior: the page you are leaving is replaced by a skeleton while the bar is also running. Use a section skeleton instead.

To show your own UI during a navigation, read the same state the bar uses with useIsRouteNavigating().

Component skeletons with Suspense

For feature pages with multiple asynchronous queries, wrap content in React <Suspense> using VitNode's pre-built pending skeletons:

plugins/site-notes/src/pages/notes-page.tsx
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import { Suspense } from 'react'

const NotesList = () => {
  // Asynchronously fetched notes
  return <div>Notes List</div>
}

const NotesPage = () => {
  return (
    <div className="container mx-auto max-w-3xl p-4">
      <Suspense fallback={<FeedPendingSkeleton rows={4} />}>
        <NotesList />
      </Suspense>
    </div>
  )
}

export default NotesPage

Pre-built skeleton shapes

Import pre-built skeletons directly from @vitnode/core/tanstack/pending:

Skeleton shapeLayout patternTypical use case
FeedPendingSkeletonCard timeline with avatars and text rowsActivity feeds, note lists, blog posts
TablePendingSkeletonSearch toolbar, column headers, and rowsAdminCP data tables, member directories
CardsPendingSkeletonResponsive 1/2/3 column card gridDashboard widgets, product catalogs
FormPendingSkeletonStacked input placeholders and action buttonsSettings forms, modal dialogs
AuthPendingSkeletonCentered authentication card with form inputsSign-in, sign-up, password reset
RoutePendingSpinnerCentered accessible spinnerSuspense fallback with no known shape

Customizing skeleton props

All skeleton shapes accept props from RoutePendingSkeletonProps to adapt to your layout:

plugins/site-notes/src/pages/notes-admin-page.tsx
import { TablePendingSkeleton } from '@vitnode/core/tanstack/pending'
import { Suspense } from 'react'

const AdminNotesPage = () => {
  return (
    <Suspense
      fallback={
        <TablePendingSkeleton
          className="p-6"
          rows={8}
          withDescription={false}
        />
      }
    >
      <AdminNotesTable />
    </Suspense>
  )
}

export default AdminNotesPage
PropTypeDefaultDescription
rowsnumber3 to 6Number of skeleton items, rows, or cards to display
withDescriptionbooleantrueWhether to display a skeleton subtitle under the heading
classNamestringundefinedAdditional Tailwind utility classes merged onto the container
labelstring'Loading'Accessible screen-reader label (aria-label)

Keep data on screen during refetches

A background refetch should never blank content that is already loaded. Show a skeleton only for the first load, which is isPending, never isFetching. For a query keyed on a search term, a filter, or a page, keep the previous result while the next one loads and mark it as busy:

plugins/site-notes/src/views/notes-search.tsx
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import { cn } from 'cn'

const NotesSearch = ({ search }: { search: string }) => {
  const query = useQuery({
    ...notesQueryOptions({ search }),
    placeholderData: keepPreviousData,
  })

  if (query.isPending) return <FeedPendingSkeleton withDescription={false} />

  return (
    <ul
      aria-busy={query.isFetching}
      className={cn(query.isPlaceholderData && 'opacity-60')}
    >
      {query.data?.map(note => <li key={note.id}>{note.title}</li>)}
    </ul>
  )
}

Show action progress on the button

Submissions and mutations show their progress on the control that started them. Pass isLoading to Button: it disables the button, swaps the label for a spinner without changing the width, and sets aria-busy. AutoForm and ConfirmAction already do this for their submit buttons.

plugins/site-notes/src/views/delete-note-button.tsx
import { Button } from '@vitnode/core/components/ui/button'
import { useMutation } from '@tanstack/react-query'

const DeleteNoteButton = ({ id }: { id: number }) => {
  const mutation = useMutation({ mutationFn: deleteNote })

  return (
    <Button
      isLoading={mutation.isPending}
      onClick={() => mutation.mutate(id)}
      variant="destructive"
    >
      Delete
    </Button>
  )
}

While the action runs, also disable any other control that would conflict with it, such as Reject next to a pending Approve.