The Best Loading States Are No Loading States
Most applications end up with skeletons, spinners, shimmer effects, loading overlays, suspense fallbacks, and all sorts of other UI whose sole purpose is to occupy the space where data should eventually appear.
We're all spending a surprising amount of time solving the same problem, and none of it is really product work, so I couldn't stop thinking about how to avoid loading states altogether.
Looking back at the history of the web, I think we already had a pretty good answer. We just forgot it for a while.
The Web Already Had an Answer
If you've been building websites since before SPAs, you'll remember that loading states weren't always everywhere.
When a user clicked a link, the browser made a request, waited for the server to respond, and then rendered the next page. The browser itself handled the waiting, which meant users never navigated into half-rendered pages because there was no such thing as a half-rendered page. The next page either wasn't ready yet, or it was.
That model wasn't perfect, but it had one really nice benefit... loading was handled at the application level rather than at the component level. We didn't need to maintain loading states scattered throughout the page because the browser was already coordinating that.
Then SPAs arrived and navigation became instant, which felt like a huge improvement. Instead of waiting for a new page to arrive, we can switch routes immediately and start rendering straight away.
The trade-off is that we often navigate before the data is ready, and at that point we have a new problem to solve. How do we fill the empty space while data loads?
The answer became skeletons, spinners, shimmer effects, suspense fallbacks, loading overlays, and countless variations of the same idea. We traded one form of waiting for another. We stopped waiting before navigation and started waiting after navigation.
Route Transitions (to the Rescue?)
What I find interesting about route transitions is that they allow us to move closer to the original web model. Not completely, because we still get all the benefits of client-side navigation, but the mental model becomes surprisingly similar.
When I say "route transitions", I'm not talking about animations. I'm talking about the ability for a router to begin a navigation, load data in the background, and delay committing the route change until everything needed for the next screen is ready.
That small shift ends up changing where loading happens entirely.
Instead of navigating, rendering, fetching data, and then gradually filling the page in as that data arrives, a link click can start loading data, await it, and then navigate to a completed page.
At first glance that sounds like a step backwards. After all, there was a good reason we moved loading into components in the first place... perceived performance.
If a user clicks a link and the application immediately responds, it feels fast because something happened. The user got feedback and the app appears responsive.
That's a reasonable goal, but users are often waiting just as long. We've just moved the waiting rather than removing it.
Route transitions give us another option. We can start loading before the user clicks, wait only when we genuinely need to, and avoid adding skeletons and loading states throughout our application without making the app feel slower.
I'm using TanStack Router examples throughout this article because its loader and preloading APIs make the pattern easy to demo, but the underlying idea applies to any routing solution that supports React route transitions.
Preload Everything
The key to making this work is preloading.
If the user is likely to need some data soon, start loading it before they ask for it. Hovering a link or a link entering the viewport are both opportunities to start loading data ahead of time.
Using TanStack Router as an example, route loaders become the natural place to define a route's data requirements.
export const Route = createFileRoute('/users/$userId')({
loader: async ({ context, params }) => {
const queryOptions = userQueryOptions(params.userId);
await context.queryClient.ensureQueryData(queryOptions);
},
component: UserProfile,
});The important part isn't the loader itself. It's that the router now knows what data a route needs ahead of time, which means it can start loading that data before navigation happens. Once you start working this way, navigation behaviour becomes surprisingly informative.
Let the UI Tell You What's Missing
One thing I like about this approach is that it creates a very clear feedback loop. Once data fetching lives in route loaders and links are preloading those loaders, the behaviour of the UI starts telling you what's wrong.
Most people see empty UI as a bug and immediately reach for a spinner. I see it as feedback.
If part of the page pops in after navigation, that's the app telling me I forgot to preload something. If the data had been preloaded correctly, the route transition would have waited for it before committing the navigation, so there shouldn't be anything left to appear afterwards.
In other words, if navigation completes and the UI is still filling itself in, that's a sign some data fetching is happening outside of the preload path.
The other signal is when a route transition takes a noticeable amount of time.
When preloading begins as links intersect or are hovered, that should give us a surprisingly useful window to fetch data before the click happens. It's often enough time for the preload to complete and for navigation to feel instant. But, if the route transition is still waiting, it usually means either the user clicked before the preload had enough time to finish, or the data is genuinely taking a long time to load.
The first case isn't particularly interesting. The second usually is.
If the data still hasn't loaded after being given a couple hundred milliseconds head start, it's worth digging into why to improve the performance issue. If the wait is unavoidable, then that's where we can surface a global loading indicator.
Just one loading state for the whole app rather than separate loading states for the table, sidebar, chart, and every other feature that happens to fetch data. And more importantly, it appears in the same place every time. Users learn what it means, rather than having to interpret a different loading experience for every feature.
GitHub's loading bar is a good example of the kind of experience I'm talking about. When navigation takes a little longer than expected, a small loading indicator appears in a consistent place until the transition completes. Most routing libraries expose some form of transition state that makes this straightforward to build, e.g. TanStack Router's useRouterState hook.
Users should rarely see it because preloading has already done the work, but it's there as a fallback for the cases where the data simply can't arrive quick enough.
I even delay showing the indicator slightly using something like spin-delay, so if a transition completes relatively quickly, the indicator never appears at all. Users don't need feedback for waits they'd never notice.
Because of this, we can stop building loading states inside components. Queries simply return data. If the data exists, render it. If it doesn't, render nothing.
const user = useQuery(userQueryOptions(userId));
if (!user.data) return null;
return <UserProfile user={user.data} />;I'm doing this deliberately.
The goal isn't to show blank UI to users. The goal is to amplify the signal during development. A skeleton hides the problem, but returning null makes it obvious.
If I navigate somewhere and part of the page is blank, I immediately know that something wasn't preloaded correctly. Those gaps in the UI become a diagnostic tool.
Rather than designing another loading state, I improve the preload strategy until the issue disappears entirely.
An application built using the approach described. No component-level loading states, skeletons, or shimmer effects.
What About Refreshing the Page?
Preloading only works when users are navigating through the application, but a refresh starts from scratch. There's no hover event, no preload, and no route transition. So, for refreshes, I take the same approach.
Rather than letting individual components decide how loading should look, I track whether the application is still settling and show a single fullscreen overlay until it does.
I do that using a small abstraction around my query library that reports loading activity to a provider. The provider tracks whether mounted queries are still loading, and renders a fullscreen overlay while the initial page load is settling.
The application still mounts underneath and the queries still execute normally, but the user doesn't see the partially rendered state while everything is loading. Once all active queries have completed, the overlay disappears and the application becomes visible.
It's not perfect. I debounce the removal of the overlay slightly so that small request waterfalls don't immediately cause content to pop in after the page has loaded, but there are still cases where data could waterfall for longer than the debounce window. I don't really see that as a loading state problem though. I see it as a signal that there's probably a waterfall worth investigating. In practice, I've found this becomes increasingly rare once most data fetching is lifted to route boundaries, i.e. moved into loaders.
In the same way that blank sections tell me I've forgotten to preload something, delayed content on a refresh often tells me that the data dependencies could be structured better.
The End Goal
We could stop here. Loading is already an application concern rather than a component concern, and users are seeing far fewer loading states. But if I'm honest, even the fullscreen overlay feels like a compromise.
The downside is that users see the overlay on every refresh, when ideally they wouldn't see loading states at all.
The main reason it exists is because a refresh starts from scratch and the application has to fetch everything again before it can become interactive. But we can persist data locally, and that changes things.
Whether that's TanStack DB, Zero, or some other approach entirely, the idea is the same... keep enough data on the device that the application can render immediately.
The first visit might require a network request and show the overlay, but their second visit often doesn't, especially once they've started navigating around the application and the local cache has had a chance to populate.
At that point the fullscreen overlay also becomes a fallback rather than part of the normal experience, the architecture stays the same, and the waits become increasingly rare.
Navigations are instant because we preload, and local persistence makes refreshes feel the same.
Conclusion
If you're finding yourself spending a surprising amount of time building loading states, it might be worth stepping back and asking a different question. Instead of asking how a component should handle loading, we can ask whether it should be loading at all.
Route transitions don't eliminate loading, but they do give us a way to move it back to where I personally believe it belongs... at the app level rather than the component level.
Once we start aggressively preloading data, something interesting happens. The waiting largely disappears.
At that point we're no longer trying to design better loading states, we're trying to make them redundant. When something does appear late, hang, or flicker into existence, don't immediately reach for a skeleton. Treat it as feedback. The application is telling us something, and more often than not, it's right.