
Data fetching is a fundamental aspect of modern React applications, yet many developers implement it in ways that lead to performance issues and unnecessarily complex components. In this article, we'll explore a superior pattern for handling data fetching that will significantly improve your React applications.
The Common (But Problematic) Data Fetching Approach
Let's start by examining a typical data fetching implementation that you've likely seen or written yourself. This approach, while functional, has several drawbacks that we'll address.
function PostPage() {
const postQuery = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId)
})
if (postQuery.isPending) {
return <div>Loading post...</div>
}
if (postQuery.isError) {
return <div>Error: {postQuery.error.message}</div>
}
return (
<div>
<h1>{postQuery.data.title}</h1>
<p>{postQuery.data.content}</p>
</div>
)
}
At first glance, this implementation seems reasonable. We're using React Query (TanStack Query) to fetch data, handling loading and error states, and rendering the post when it's available. However, this approach has two significant drawbacks.

The Problems with In-Component Data Fetching
1. Performance Issues Due to Multiple Renders
When you fetch data inside a component, the component initially renders with undefined data. Then it renders again in a loading state, and finally renders a third time with the actual data. This cascading render pattern becomes increasingly problematic as you add more queries to a component.
function PostWithComments() {
// First query
const postQuery = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId)
})
// Second query
const commentsQuery = useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId)
})
// Now we need to handle multiple loading/error states
if (postQuery.isPending || commentsQuery.isPending) {
return <div>Loading...</div>
}
if (postQuery.isError) {
return <div>Post error: {postQuery.error.message}</div>
}
if (commentsQuery.isError) {
return <div>Comments error: {commentsQuery.error.message}</div>
}
// Component content with both data sources
return (
<div>
<h1>{postQuery.data.title}</h1>
<p>{postQuery.data.content}</p>
<h2>Comments</h2>
<ul>
{commentsQuery.data.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
</div>
)
}
With each added query, you get more independent rerenders, making your component less efficient. This becomes even more problematic when React Query refetches data on window focus or other events, causing additional rerenders.
2. Component Complexity and Code Bloat
As demonstrated above, handling multiple queries within a component quickly becomes unwieldy. You need to manage various loading and error states, add optional chaining for potentially undefined values, and implement complex conditional rendering logic. This bloats your components with data fetching concerns when they should focus on presentation.
The Better Pattern: Route-Level Data Fetching
The superior approach is to move data fetching to the router level, where it naturally belongs. This aligns with the fact that data requirements are typically tied to navigation - when a user visits a certain URL, they expect to see specific data.

Modern routing libraries like Tanstack Router, React Router, or Next.js App Router support this pattern. Here's how it looks with Tanstack Router:
// In your route definition
const postRoute = createRoute({
path: '/posts/$postId',
loader: ({ params }) => ({
postQuery: {
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
},
commentsQuery: {
queryKey: ['comments', params.postId],
queryFn: () => fetchComments(params.postId),
},
}),
component: PostPage,
})
// Your component is now much simpler
function PostPage() {
// Data is already loaded and available
const { postQuery, commentsQuery } = useLoaderData()
return (
<div>
<h1>{postQuery.data.title}</h1>
<p>{postQuery.data.content}</p>
<h2>Comments</h2>
<ul>
{commentsQuery.data.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
</div>
)
}
Benefits of Route-Level Data Fetching
- Data fetching starts earlier in the navigation process, even before the component code is loaded
- Components receive data that's already loaded, eliminating the undefined → loading → data render cascade
- Components become simpler, focusing on presentation rather than data fetching logic
- Loading and error states can be handled centrally at the router level
- Parallel data fetching is easier to implement and manage
Implementing Advanced Loading States
With route-level data fetching, you can implement more sophisticated loading patterns. For example, you can use React Suspense to show fallback UI while data is loading:
// In your route component
function PostRouteComponent() {
return (
<Suspense fallback={<PostSkeleton />}>
<PostPage />
</Suspense>
)
}

Handling Error States Gracefully
Error handling also becomes more structured with route-level data fetching. You can implement error boundaries at the route level or use router-specific error handling:
// Error handling with Tanstack Router
const postRoute = createRoute({
path: '/posts/$postId',
loader: ({ params }) => ({
postQuery: {
queryKey: ['post', params.postId],
queryFn: () => fetchPost(params.postId),
}
}),
component: PostPage,
errorComponent: PostErrorPage
})
// Dedicated error component
function PostErrorPage({ error }) {
return (
<div className="error-container">
<h1>Error Loading Post</h1>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>Try Again</button>
</div>
)
}
Practical Implementation Tips
- Use the route loader pattern with any modern React router (Tanstack Router, React Router v6+, Next.js App Router)
- Prefetch data for anticipated routes to make navigation feel instant
- Consider implementing stale-while-revalidate patterns for data that changes frequently
- Use skeleton loaders for a better user experience during initial data fetching
- Implement proper error boundaries to gracefully handle failed data fetches
Conclusion
Moving data fetching from components to the router level represents a significant improvement in React application architecture. This pattern aligns data requirements with navigation, simplifies components, improves performance, and creates a better developer experience. While it may require restructuring existing applications, the benefits in code maintainability and user experience make it well worth the effort.
Next time you start a new React project or refactor an existing one, consider implementing route-level data fetching. Your future self (and your users) will thank you for the cleaner code and improved performance.
Let's Watch!
The Ultimate React Data Fetching Pattern Every Developer Should Adopt
Ready to enhance your neural network?
Access our quantum knowledge cores and upgrade your programming abilities.
Initialize Training Sequence