The Best Loading States Are No Loading States
Short summary
The article argues that instead of sprinkling spinners and skeletons throughout components, you can shift data fetching to route-level loaders and preload data on hover or in-view before navigation. Using route transitions lets you delay committing a route change until all required data is ready, keeping the app fast and eliminating most loading UI. A global fallback indicator covers refreshes or genuinely slow loads, while blank spots during development highlight missed preloads.
- Route-level loaders that preload data on hover/in-view let routers delay navigation until data is ready, eliminating most component-level spinners and skeletons.
- Blank spots during development (rather than hidden skeletons) reveal exactly which route loaders are missing preload logic.
- A single global loading indicator (like GitHub's top progress bar) handles genuinely slow loads instead of scattering local spinners throughout the UI.
- Component data-fetching logic simplifies to a binary: render the data if it exists, or render nothing at all.
Full summary
Modern single-page apps (SPAs) trade full-page reloads for instant route changes, but that shift pushed loading states from browser-driven page loads into individual components. Developers filled empty screens with spinners, skeletons, shimmer effects and overlays—work that isn’t product-focused. Route transitions flip this. Instead of navigating immediately into blank components, routers can fetch all needed data in the background, then “commit” the new route once everything’s ready. You get fast-feeling navigation without fragmentary loading UI.
Key to it all is preloading. Define each route’s data requirements in a loader function—TanStack Router’s loader API makes this explicit—then kick off those fetches on link hover or when elements scroll into view. When a user finally clicks, the router either navigates instantly (if data’s already in cache) or delays until loading finishes. Missing data shows up as blank spots in development, so you spot gaps in your preload logic instead of masking them behind skeletons. For true slow queries, you surface a single, global loading bar—GitHub’s top-of-page progress bar is a solid example—rather than sprinkling dozens of local spinners across your UI.
This approach turns your UI into its own health check. If a component renders null after navigation, you know exactly which route loader needs tweaking. And because you preload aggressively, users seldom see the global loader. When they do, it always appears in the same place, so they learn its meaning. Queries themselves stay simple: if data exists, render it; otherwise render nothing. That discipline pushes you to refine data-fetching paths until loading states vanish completely from your components.