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.
Navigation progress bar
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:
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.
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:
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 NotesPagePre-built skeleton shapes
Import pre-built skeletons directly from @vitnode/core/tanstack/pending:
| Skeleton shape | Layout pattern | Typical use case |
|---|---|---|
FeedPendingSkeleton | Card timeline with avatars and text rows | Activity feeds, note lists, blog posts |
TablePendingSkeleton | Search toolbar, column headers, and rows | AdminCP data tables, member directories |
CardsPendingSkeleton | Responsive 1/2/3 column card grid | Dashboard widgets, product catalogs |
FormPendingSkeleton | Stacked input placeholders and action buttons | Settings forms, modal dialogs |
AuthPendingSkeleton | Centered authentication card with form inputs | Sign-in, sign-up, password reset |
RoutePendingSpinner | Centered accessible spinner | Suspense fallback with no known shape |
Customizing skeleton props
All skeleton shapes accept props from RoutePendingSkeletonProps to adapt to your layout:
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| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 3 to 6 | Number of skeleton items, rows, or cards to display |
withDescription | boolean | true | Whether to display a skeleton subtitle under the heading |
className | string | undefined | Additional Tailwind utility classes merged onto the container |
label | string | '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:
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.
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.