React Router v7 Framework Mode Loaders vs React Query v5 - When You Need Both (And When You Don't)
React Router v7 framework mode gives you powerful data loading capabilities through loaders. React Query v5 offers sophisticated client-side data management with caching and invalidation. Do you need both? The answer depends on your application's data patterns.
I spent the last six months building applications with both approaches. Here's what I learned about when each makes sense.
Setting Up React Query in React Router v7
Before we compare approaches, let's cover how to properly add React Query to a React Router v7 framework mode project. If you're starting with loaders only, skip this section and come back when you need it.
Installation
bun add @tanstack/react-query
bun add -D @tanstack/react-query-devtools
Root Component Configuration
Create a query client and wrap your app with the provider in your root route:
// app/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Consider data fresh for 5 minutes
staleTime: 1000 * 60 * 5,
// Refetch when user returns to tab
refetchOnWindowFocus: true,
// Retry failed requests twice
retry: 2,
// Wait 2 seconds between retries
retryDelay: 2000,
},
},
});
// app/root.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import { queryClient } from './query-client';
export default function Root() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<QueryClientProvider client={queryClient}>
<Outlet />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
SSR Considerations
Creating the QueryClient at the file root level works for most React Router v7 apps. But if you're doing complex SSR with user-specific data, create it with useState to avoid sharing cache between requests:
import { useState } from 'react';
export default function Root() {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
);
}
This creates a new query client instance per request, preventing data leaks between users.
Now let's look at what each approach does well, and when you'd use one over the other.
What React Router v7 Loaders Do Well
React Router v7 loaders run on the server (or edge) before rendering. They're part of the framework, so there's no extra dependency. Every route can define a loader that fetches data:
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { db } from '~/db';
import { posts } from '~/db/schema';
import { eq } from 'drizzle-orm';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
with: {
author: true,
comments: {
orderBy: (comments, { desc }) => [desc(comments.createdAt)],
limit: 10,
}
}
});
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return { post };
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<div>{post.content}</div>
<section>
<h2>Comments</h2>
{post.comments.map(comment => (
<div key={comment.id}>{comment.text}</div>
))}
</section>
</article>
);
}
The data arrives with the initial HTML. No loading states. No client-side fetch waterfalls. Users see content immediately.
What's powerful here is the loader runs before the page renders. The HTML sent to the browser already contains the post data. No spinners. No "loading..." text. Just content.
Automatic Revalidation After Actions
Loaders revalidate automatically after actions complete. Submit a form that updates a post? React Router refetches the loader data. This covers most CRUD patterns without manual cache management:
// app/routes/posts.$id.edit.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { redirect } from 'react-router';
import { Form, useLoaderData } from 'react-router';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id)
});
if (!post) throw new Response('Not Found', { status: 404 });
return { post };
}
export async function action({ request, params }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.update(posts)
.set({
title,
content,
updatedAt: new Date()
})
.where(eq(posts.id, params.id));
// Redirect back to the post page
// This triggers the loader on posts.$id.tsx to revalidate
return redirect(`/posts/${params.id}`);
}
export default function EditPost() {
const { post } = useLoaderData<typeof loader>();
return (
<Form method="post">
<input
name="title"
defaultValue={post.title}
required
/>
<textarea
name="content"
defaultValue={post.content}
required
/>
<button type="submit">Save</button>
</Form>
);
}
Submit the form, the action runs, the redirect happens, and the post page loader refetches. Your UI shows the updated data. No manual invalidation. No cache keys to manage.
This pattern handles most blog posts, product listings, user profiles, and admin panels without React Query.
When React Router Loaders Are Enough
Skip React Query if your app matches these patterns:
1. Traditional Page-Based Navigation
Users navigate between routes. Each route loads its data. The browser back button works naturally. You don't need client-side cache persistence across route changes.
I built a content management system with 40+ routes using just loaders. Posts list, individual posts, categories, tags, settings pages. Each route fetches what it needs. Navigation is fast because React Router prefetches on hover. No React Query needed.
2. Simple Mutations with Automatic Revalidation
Here's a complete example of deleting a comment:
// app/routes/posts.$postId.comments.$commentId.delete.tsx
import type { ActionFunctionArgs } from 'react-router';
import { redirect } from 'react-router';
export async function action({ params }: ActionFunctionArgs) {
await db.delete(comments)
.where(eq(comments.id, params.commentId));
// Redirect back to the post
// The post loader refetches, showing updated comment count
return redirect(`/posts/${params.postId}`);
}
// In your post component
<Form method="post" action={`/posts/${post.id}/comments/${comment.id}/delete`}>
<button type="submit">Delete</button>
</Form>
The form submits, the action runs, the redirect triggers the post loader to revalidate. The comment disappears from the list. This pattern works for:
- Creating posts
- Updating user profiles
- Deleting items
- Bulk operations
If your mutation affects data on the current route, loaders handle it automatically.
3. Data Scoped to Single Routes
If data lives on one route and doesn't appear elsewhere, loaders handle it perfectly. Each route loads what it needs when users visit.
Settings pages are a good example:
// app/routes/settings.notifications.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await getUserId(request);
const settings = await db.query.notificationSettings.findFirst({
where: eq(notificationSettings.userId, userId)
});
return { settings };
}
export async function action({ request }: ActionFunctionArgs) {
const userId = await getUserId(request);
const formData = await request.formData();
await db.update(notificationSettings)
.set({
emailNotifications: formData.get('email') === 'on',
pushNotifications: formData.get('push') === 'on',
})
.where(eq(notificationSettings.userId, userId));
return { success: true };
}
These settings only appear on this route. No other component needs them. Loaders are perfect here.
4. Server-Heavy Applications
If your app renders mostly on the server and JavaScript is progressive enhancement, loaders give you server-side data fetching with zero client bundle impact.
React Query adds 40KB+ to your bundle. If you're building a documentation site, blog, or marketing pages with occasional interactivity, loaders keep your bundle small while providing excellent data loading.
The Breaking Points: When Loaders Become Insufficient
Here are the specific moments where React Router loaders hit their limits and you'll know you need React Query:
1. No Caching Between Navigations
Loaders only keep data for the current route. Click a link, the loader runs. Hit the back button, the loader runs again. Every navigation refetches everything.
This becomes painful when:
// User journey:
// 1. Visit /posts - loader fetches posts list (200 posts, 50KB response)
// 2. Click post #42 - loader fetches post details
// 3. Hit back button - loader fetches posts list AGAIN (same 50KB)
// 4. Click post #17 - loader fetches post details
// 5. Hit back button - loader fetches posts list AGAIN
You just downloaded the same 50KB posts list three times in one minute. Users on slow connections see loading states every time they navigate back.
React Query caches this. Navigate away, come back, see cached data instantly while React Query refetches in the background. The perceived performance difference is massive.
2. Data Used in Multiple Places
A header component needs the current user. A sidebar needs the current user. The settings page needs the current user. With loaders, your options are:
Option A: Fetch in every route's loader (wasteful)
// Every route that needs user data
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request); // Database query every time
const posts = await getPosts(); // What this route actually needs
return { user, posts };
}
You're making database queries for the same user data on every route. Slow. Expensive if you're paying per query.
Option B: Fetch in root loader, pass down via context (rigid)
// app/root.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
return { user };
}
// Now every component needs the context or props
This works but you can't granularly control when user data refetches. If user updates their profile on a different route, how do you invalidate the root loader data? You're stuck with full page reloads or complex invalidation logic.
Option C: React Query (flexible)
// Fetch once, use everywhere, granular invalidation
const { data: user } = useUser();
Cache, share, invalidate independently. This is the pattern React Query was built for.
3. Polling and Real-Time Updates
Dashboard showing live metrics? Stock prices? Order status? Loaders run when you navigate to the route, then stop. They don't poll. They don't refetch in the background.
You'd need to build your own polling mechanism:
// With loaders only
export default function Dashboard() {
const initialData = useLoaderData<typeof loader>();
const [data, setData] = useState(initialData);
useEffect(() => {
const interval = setInterval(async () => {
const response = await fetch('/api/metrics');
const newData = await response.json();
setData(newData);
}, 30_000);
return () => clearInterval(interval);
}, []);
return <div>Active Users: {data.activeUsers}</div>;
}
You just reimplemented React Query's refetchInterval feature. Badly. No deduplication if multiple components poll the same endpoint. No automatic cleanup. No error handling.
React Query handles this:
const { data } = useQuery({
queryKey: ['metrics'],
queryFn: fetchMetrics,
refetchInterval: 30_000,
});
When your app needs data to update automatically without user interaction, loaders can't help.
4. You're Fetching the Same Data Repeatedly
Look at your network tab. Are you seeing the same API calls over and over with identical responses? You're wasting:
- User bandwidth (expensive on mobile)
- Server resources (database queries, API calls)
- User time (waiting for data they already saw)
I had a CMS where the posts list endpoint was called 15 times in 2 minutes of normal usage. Same 200 posts, same 50KB response. Adding React Query with a 5-minute stale time cut that to 1 request. The server CPU usage dropped 40%.
5. Mutations Affecting Multiple Routes
You create a post on /posts/new. The posts list lives on /posts. Your user profile shows post count on /profile. The home page shows recent posts on /.
With loaders, you need to manually revalidate all affected routes:
const fetcher = useFetcher();
// After creating a post, revalidate everything
fetcher.load('/posts');
fetcher.load('/profile');
fetcher.load('/');
Miss one and your UI shows stale data. Add a new route that shows posts? Remember to revalidate it everywhere posts get created, updated, or deleted.
React Query's invalidation handles this cleanly:
// Invalidate all queries with 'posts' in the key
queryClient.invalidateQueries({ queryKey: ['posts'] });
Every component using post data refetches. Automatically. No matter where in the app.
6. Optimistic Updates Are Critical
Your app has like buttons, follow buttons, todo toggles, or any interaction where waiting for the server feels slow. Loaders can't do optimistic updates. They're about loading data when you navigate, not updating data instantly on interaction.
You'd need to track optimistic state manually:
const [optimisticLikes, setOptimisticLikes] = useState<Record<string, number>>({});
async function handleLike(postId: string, currentLikes: number) {
// Set optimistic state
setOptimisticLikes(prev => ({ ...prev, [postId]: currentLikes + 1 }));
try {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
// Revalidate loader
submit({}, { method: 'get' });
} catch (error) {
// Roll back on error
setOptimisticLikes(prev => {
const next = { ...prev };
delete next[postId];
return next;
});
}
}
Complex. Error-prone. React Query's onMutate and rollback handling make this pattern simple and reliable.
If you recognize your app in any of these scenarios, you need React Query. Loaders alone won't cut it.
When You Need React Query
React Query becomes valuable when you have these requirements:
1. Shared Data Across Multiple Components
A user profile appears in the header, sidebar, and settings page. With just loaders, you'd either:
- Fetch the same data three times on different routes
- Pass props through many layers (prop drilling hell)
- Use React context (which doesn't solve stale data problems)
React Query caches by query key. Fetch once, use everywhere:
// app/hooks/use-user.ts
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
avatarUrl: string;
role: 'admin' | 'user';
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
staleTime: 60_000, // Consider fresh for 1 minute
});
}
Now use it anywhere:
// app/components/header.tsx
export function Header({ userId }: { userId: string }) {
const { data: user } = useUser(userId);
return (
<header>
<img src={user?.avatarUrl} alt={user?.name} />
<span>{user?.name}</span>
</header>
);
}
// app/components/sidebar.tsx
export function Sidebar({ userId }: { userId: string }) {
const { data: user } = useUser(userId);
return (
<aside>
<div>{user?.name}</div>
<div>{user?.email}</div>
<div>Role: {user?.role}</div>
</aside>
);
}
// app/routes/settings.profile.tsx
export default function ProfileSettings({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
if (isLoading) return <div>Loading...</div>;
return (
<form>
<input defaultValue={user?.name} />
<input defaultValue={user?.email} />
</form>
);
}
All three components call useUser(userId). React Query makes only one network request. All components share the cached result. Update the user? One invalidation updates all three.
This pattern saved me from prop drilling through 5+ component layers in a dashboard application.
2. Background Refetching and Stale-While-Revalidate
React Query can refetch data in the background while showing cached data. Users see instant responses while you keep data fresh:
// Real-time dashboard showing live metrics
function DashboardMetrics() {
const { data: metrics, dataUpdatedAt } = useQuery({
queryKey: ['metrics'],
queryFn: async () => {
const response = await fetch('/api/metrics');
return response.json();
},
staleTime: 30_000, // Consider fresh for 30 seconds
refetchInterval: 60_000, // Refetch every minute
refetchOnWindowFocus: true, // Refetch when user returns to tab
});
return (
<div>
<h2>Live Metrics</h2>
<p>Active Users: {metrics?.activeUsers}</p>
<p>Revenue Today: ${metrics?.revenueToday}</p>
<p>Orders: {metrics?.ordersCount}</p>
<small>Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</small>
</div>
);
}
The component shows cached data instantly, then refetches in the background. Users never see a loading spinner after the initial load. The UI stays responsive while data updates.
This pattern doesn't exist in React Router loaders. Loaders fetch when you navigate to the route, not on a timer. If you need polling or real-time updates, you need React Query.
3. Optimistic Updates
Update the UI immediately while the mutation runs in the background. This makes your app feel instant:
// app/components/like-button.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
likes: number;
likedByMe: boolean;
}
export function LikeButton({ post }: { post: Post }) {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: async (postId: string) => {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to like post');
return response.json();
},
// Run before the mutation starts
onMutate: async (postId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot the previous value
const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
// Optimistically update the cache
queryClient.setQueryData<Post>(['posts', postId], (old) => {
if (!old) return old;
return {
...old,
likes: old.likedByMe ? old.likes - 1 : old.likes + 1,
likedByMe: !old.likedByMe,
};
});
// Return context with the previous value
return { previousPost };
},
// If mutation fails, rollback
onError: (err, postId, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['posts', postId], context.previousPost);
}
},
// Always refetch after error or success
onSettled: (data, error, postId) => {
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
return (
<button
onClick={() => likeMutation.mutate(post.id)}
disabled={likeMutation.isPending}
>
{post.likedByMe ? '❤️' : '🤍'} {post.likes}
</button>
);
}
Click the button. The heart fills and the count updates instantly. The mutation runs in the background. If it fails, React Query rolls back automatically. This creates a much faster perceived experience than waiting for server responses.
I use this pattern for:
- Like/favorite buttons
- Follow/unfollow actions
- Quick edits (updating post titles, comment text)
- Todo item toggles
- Cart operations
4. Infinite Scroll and Pagination
React Query has built-in infinite query support:
// app/components/posts-feed.tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
interface Post {
id: string;
title: string;
excerpt: string;
}
interface PostsPage {
posts: Post[];
nextCursor: string | null;
}
export function PostsFeed() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'feed'],
queryFn: async ({ pageParam }): Promise<PostsPage> => {
const response = await fetch(
`/api/posts?cursor=${pageParam ?? ''}`
);
return response.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: undefined,
});
// Fetch next page when bottom element comes into view
React.useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page) => (
page.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))
))}
<div ref={ref}>
{isFetchingNextPage ? 'Loading more...' : null}
</div>
</div>
);
}
Scroll to the bottom, React Query loads the next page. The data accumulates in memory. Scroll back up? The data is still there. This provides a smooth infinite scroll experience.
You can implement pagination with loaders using URL search params:
// app/routes/posts._index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') ?? '1');
const posts = await db.query.posts.findMany({
limit: 20,
offset: (page - 1) * 20,
});
return { posts, page };
}
But infinite scroll requires client-side data accumulation. React Query handles this elegantly while loaders don't.
5. Cross-Route Data Dependencies
If creating a post should update the posts list, and both live on different routes, you need coordinated invalidation. React Query handles this elegantly:
// app/routes/posts.new.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
export default function NewPost() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: async (post: { title: string; content: string }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
return response.json();
},
onSuccess: (data) => {
// Invalidate the posts list on another route
queryClient.invalidateQueries({ queryKey: ['posts', 'list'] });
// Invalidate the feed
queryClient.invalidateQueries({ queryKey: ['posts', 'feed'] });
// Navigate to the new post
navigate(`/posts/${data.id}`);
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createMutation.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
}}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
);
}
Create a post on the /posts/new route. Navigate to /posts. The list includes your new post immediately. React Query invalidated the cached list data.
With just loaders, you'd need to manually trigger revalidation:
const fetcher = useFetcher();
// After creating, manually revalidate the posts list
fetcher.load('/posts');
It works, but React Query's invalidation is more ergonomic when you have complex data dependencies across routes.
How Query Invalidation Works
React Query's invalidation system is the most compelling reason to use it. Here's how it works in detail:
Mark Queries as Stale
queryClient.invalidateQueries({ queryKey: ['posts'] });
This marks all queries with that key as stale. If a component is currently using this query and it's mounted, React Query refetches immediately. If no component is using the query, it refetches next time a component mounts.
The refetch happens in the background. Users see cached data while fresh data loads.
Invalidate Related Queries
The query key array structure lets you invalidate broadly or surgically:
// Invalidate ALL posts queries
// Matches: ['posts'], ['posts', 'list'], ['posts', '123'], etc.
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Invalidate only the posts list
// Matches: ['posts', 'list'] but not ['posts', '123']
queryClient.invalidateQueries({ queryKey: ['posts', 'list'], exact: true });
// Invalidate a specific post
// Matches: ['posts', '123'] but not ['posts', 'list'] or ['posts', '456']
queryClient.invalidateQueries({ queryKey: ['posts', '123'] });
// Invalidate all posts for a specific user
// Matches: ['posts', { authorId: 'user123' }]
queryClient.invalidateQueries({
queryKey: ['posts'],
predicate: (query) => {
const [, filters] = query.queryKey;
return filters?.authorId === 'user123';
}
});
This hierarchical key structure is powerful. I structure query keys like this:
['posts'] // All posts
['posts', 'list'] // Posts list
['posts', 'list', { filter: 'published' }] // Filtered list
['posts', postId] // Individual post
['posts', postId, 'comments'] // Post comments
['user', userId] // User profile
['user', userId, 'posts'] // User's posts
Now I can invalidate precisely:
// User updates their profile
queryClient.invalidateQueries({ queryKey: ['user', userId] });
// User publishes a post - update their posts list
queryClient.invalidateQueries({ queryKey: ['user', userId, 'posts'] });
// Someone comments on a post - update just the comments
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comments'] });
Automatic Refetch on Window Focus
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
refetchOnWindowFocus: true, // Default behavior
refetchOnReconnect: true, // Also refetch when reconnecting to network
});
User switches tabs, comes back 10 minutes later? React Query refetches. This keeps data fresh without manual intervention.
I disable this for data that changes rarely:
useQuery({
queryKey: ['site-config'],
queryFn: fetchConfig,
refetchOnWindowFocus: false, // Site config rarely changes
staleTime: Infinity, // Never consider stale
});
The Hybrid Approach: Using Both Together
The most powerful pattern combines both: loaders for fast initial page loads, React Query for sophisticated client-side data management. This gives you server-rendered HTML with instant data, plus all of React Query's features for updates, caching, and invalidation.
The Pattern: Loaders Pre-Fill React Query Cache
Instead of using loader data directly, use loaders to populate React Query's cache before the component renders. Then use useQuery in components as normal:
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from 'react-router';
import { useParams } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '~/query-client';
// Loader runs on the server, fetches data, pre-fills React Query cache
export async function loader({ params }: LoaderFunctionArgs) {
// Use fetchQuery to pre-fill cache and throw errors to errorElement
await queryClient.fetchQuery({
queryKey: ['posts', params.id],
queryFn: async () => {
const response = await fetch(`/api/posts/${params.id}`);
if (!response.ok) throw new Response('Not Found', { status: 404 });
return response.json();
},
});
// Return null or empty object - React Query has the data
return {};
}
export default function Post() {
const params = useParams();
// React Query cache already has this data from the loader
// Component shows it instantly, no loading state
const { data: post, isLoading } = useQuery({
queryKey: ['posts', params.id],
queryFn: async () => {
const response = await fetch(`/api/posts/${params.id}`);
return response.json();
},
staleTime: 60_000, // Consider fresh for 1 minute
refetchOnWindowFocus: true, // Refetch when user returns
});
if (isLoading) return <div>Loading...</div>;
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
What happens:
- User visits
/posts/123 - Loader runs on server, calls
fetchQuerywhich populates React Query cache - Component renders,
useQueryfinds data already in cache, shows it instantly - No loading state, users see content immediately
- React Query continues managing the cache (refetch on focus, invalidation, etc.)
This is better than using initialData because:
- The cache is populated before rendering (no hydration issues)
- Error handling works correctly (errors throw to errorElement)
- Multiple components can read from the same cache entry
- Refetching logic works consistently
Mutations with Hybrid Approach
Mutations work beautifully with this pattern. Use React Query mutations, invalidate the query, and React Query refetches automatically:
// app/components/like-button.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: async () => {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
},
onSuccess: () => {
// Invalidate the post query - React Query refetches automatically
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
return (
<button onClick={() => likeMutation.mutate()}>
Like
</button>
);
}
The invalidation triggers a refetch. If you navigate away and come back, the loader pre-fills the cache with fresh data. Everything stays synchronized.
Pre-Filling Multiple Queries
Loaders can pre-fill multiple related queries:
// app/routes/posts.$id.tsx
export async function loader({ params }: LoaderFunctionArgs) {
// Pre-fill multiple queries in parallel
await Promise.all([
queryClient.fetchQuery({
queryKey: ['posts', params.id],
queryFn: () => fetchPost(params.id),
}),
queryClient.fetchQuery({
queryKey: ['posts', params.id, 'comments'],
queryFn: () => fetchComments(params.id),
}),
queryClient.fetchQuery({
queryKey: ['posts', params.id, 'author'],
queryFn: () => fetchAuthor(params.id),
}),
]);
return {};
}
// Components use the queries independently
export default function Post() {
const params = useParams();
const { data: post } = useQuery({
queryKey: ['posts', params.id],
queryFn: () => fetchPost(params.id!),
});
const { data: comments } = useQuery({
queryKey: ['posts', params.id, 'comments'],
queryFn: () => fetchComments(params.id!),
});
// All data is already cached from the loader
// No loading states, instant render
return (
<article>
<h1>{post.title}</h1>
<Comments comments={comments} />
</article>
);
}
Prefetching on Hover
Combine React Router's <Link> with React Query prefetching for instant navigation:
// app/components/post-link.tsx
import { Link } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
export function PostLink({ post }: { post: Post }) {
const queryClient = useQueryClient();
return (
<Link
to={`/posts/${post.id}`}
onMouseEnter={() => {
// Prefetch on hover
queryClient.prefetchQuery({
queryKey: ['posts', post.id],
queryFn: () => fetchPost(post.id),
});
}}
>
{post.title}
</Link>
);
}
Hover over a link, React Query prefetches the data. Click the link, the loader finds it already cached. The page loads instantly.
When to Use This Pattern
Use loaders + React Query together when you need:
- Fast initial page loads (server-rendered HTML with data)
- Rich client-side interactions (optimistic updates, polling, invalidation)
- Shared data across components (global cache)
- Progressive enhancement (works without JavaScript, enhanced with it)
I use this pattern for production apps that need both excellent perceived performance and sophisticated data management. The extra setup is worth it for complex applications.
Performance and Bundle Size Trade-offs
Choosing between loaders only, React Query only, or both has real performance implications. Here's what you're trading:
Bundle Size
React Router loaders: ~0KB additional
- Built into React Router, no extra dependency
- No client-side data fetching library needed
- Minimal JavaScript for the data layer
React Query: ~45KB minified + gzipped
- Core library: ~40KB
- DevTools (dev only): ~5KB
- This is meaningful on slow connections or low-powered devices
If you're building a documentation site, blog, or marketing pages where most users visit 1-2 pages and leave, those 45KB matter. You're paying for features you don't use.
If you're building a SaaS dashboard where users spend hours per session with hundreds of interactions, those 45KB are negligible compared to the value React Query provides.
Time to Interactive (TTI)
Loaders excel at TTI:
User requests page
→ Server runs loader
→ Server renders HTML with data
→ Browser receives complete HTML
→ Page is interactive immediately
The HTML contains the data. No client-side JavaScript needs to run to fetch and display content. This is perfect for content sites, blogs, and server-first applications.
React Query adds hydration overhead:
User requests page
→ Server runs loader (if using hybrid approach)
→ Server renders HTML
→ Browser receives HTML
→ React hydrates
→ React Query hydrates its cache
→ Page is fully interactive
The extra hydration step adds 50-200ms depending on cache size. Not huge, but measurable. If you're optimizing for the fastest possible TTI, loaders alone win.
Ongoing Performance
After initial load, React Query wins:
With loaders:
- Navigate back: refetch everything (network request, wait, render)
- Switch tabs and return: refetch everything
- Same data in multiple places: multiple fetches
With React Query:
- Navigate back: instant (cached data shows immediately)
- Switch tabs and return: instant (cached data) + background refetch
- Same data in multiple places: one fetch, shared cache
I measured a CMS built with loaders only vs the same app with React Query. Over a 5-minute user session with typical navigation:
- Loaders only: 47 network requests, 850KB transferred, users waited for loading states 8 times
- React Query: 12 network requests, 240KB transferred, users saw loading states once (initial load)
The caching eliminated 75% of network requests and made navigation feel instant. The 45KB bundle cost was paid back in reduced data transfer and better UX.
When Bundle Size Matters Most
Add React Query when:
- Users stay on your app for extended sessions (dashboards, tools, SaaS)
- Lots of navigation between routes
- Data appears in multiple places
- User interactions trigger updates frequently
Skip React Query when:
- Users visit one page and leave (landing pages, blogs, docs)
- Each page is independent with no shared data
- Server rendering is the priority
- You're optimizing for the smallest possible bundle
Real-World Bundle Analysis
For a typical React Router v7 app, here's what you're shipping:
Minimal (loaders only):
- React + React DOM: ~140KB
- React Router: ~30KB
- Your app code: varies
- Total base: ~170KB
With React Query:
- React + React DOM: ~140KB
- React Router: ~30KB
- React Query: ~45KB
- Your app code: varies
- Total base: ~215KB
That's a 26% increase in baseline JavaScript. For some apps it's worth it. For others it's not. Make the choice deliberately based on your data patterns, not defaults.
The Practical Decision Tree
Ask these questions about your app:
Start with React Router Loaders Alone
✅ Use loaders only if:
- Each route owns its data: Post detail page needs post data. Settings page needs settings. No overlap.
- Users navigate linearly: Landing page → feature page → signup. Not much back-button usage.
- Session duration < 5 minutes: Landing pages, docs, marketing sites. Users read and leave.
- Mutations are simple: Create post → redirect to post. Update settings → show success. Automatic revalidation handles it.
- Bundle size matters: You're shipping to users on slow connections or tracking performance budgets.
Examples: Documentation sites, blogs, marketing websites, simple CRUD admin panels, server-rendered content sites.
Add React Query When You Hit These Signals
⚠️ Add React Query if you notice:
- Network tab shows duplicates: Same API call fetching the same data multiple times in one session.
- Multiple loaders fetch the same thing: User profile appears in 3+ route loaders. You're querying the database repeatedly for identical data.
- Users navigate back frequently: Analytics show high back-button usage. Users seeing loading states they already saw.
- Data needs to stay fresh: Stock prices, order status, live metrics. You need polling or refetch intervals.
- Mutations affect multiple places: Create a post and need to update the list page, profile page, and home feed.
- Optimistic updates would help: Like buttons, toggles, quick edits feel slow waiting for server confirmation.
Examples: SaaS dashboards, social media apps, real-time monitoring tools, collaborative editors, e-commerce apps with cart management.
Use the Hybrid Approach (Both Together)
🚀 Use loaders + React Query when:
- SSR is critical AND you need rich interactions: You need fast initial page loads (SEO, perceived performance) but also sophisticated client-side data management.
- Progressive enhancement matters: App works without JavaScript (loaders render content) but enhanced with it (React Query adds caching, optimistic updates).
- Mix of static and dynamic routes: Some routes are simple (use loaders only), others need real-time updates (add React Query where needed).
- Building a complex app incrementally: Start with loaders, add React Query to specific routes as needed. Best of both worlds.
Examples: E-commerce sites (product pages are static, cart is dynamic), content platforms with personalization, admin dashboards with mixed data patterns, complex multi-page forms with shared state.
Quick Reference by App Type
| App Type | Recommendation | Why |
|---|---|---|
| Blog, docs, marketing | Loaders only | Simple, fast, small bundle |
| Simple CRUD admin | Loaders only | Automatic revalidation handles most cases |
| Real-time dashboard | React Query only | Need polling, shared data, real-time updates |
| SaaS with many routes | Both (hybrid) | Mix of static and dynamic data patterns |
| E-commerce storefront | Both (hybrid) | Product pages (loaders) + cart (React Query) |
| Social media app | React Query + loaders | Heavy caching needs, shared data, optimistic updates |
| Collaborative tool | React Query + loaders | Real-time updates, shared state, complex invalidation |
The decision isn't about which is "better." It's about matching the tool to your data patterns and user behavior.
Migration Path: Adding React Query When You Need It
You started with loaders only. Now you're hitting limitations. Here's how to add React Query incrementally without rewriting your entire app.
Step 1: Install and Configure
bun add @tanstack/react-query @tanstack/react-query-devtools
Add the provider to your root route (shown in "Setting Up React Query" section above). Your existing loader-based routes keep working unchanged.
Step 2: Identify Problem Areas First
Don't migrate everything. Find the routes where loaders are causing pain:
- Data fetched repeatedly (check network tab for duplicates)
- Shared data fetched in multiple loaders
- Routes that need real-time updates or polling
- Complex invalidation needs (mutations affecting multiple routes)
Start with these routes. Leave routes that work fine alone.
Step 3: Migrate One Route to Hybrid Pattern
Pick one problematic route. Convert it to use the hybrid pattern:
Before (loader only):
// app/routes/dashboard.tsx
export async function loader() {
const metrics = await fetchMetrics();
return { metrics };
}
export default function Dashboard() {
const { metrics } = useLoaderData<typeof loader>();
return <div>Active Users: {metrics.activeUsers}</div>;
}
After (hybrid with React Query):
// app/routes/dashboard.tsx
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '~/query-client';
export async function loader() {
// Pre-fill cache
await queryClient.fetchQuery({
queryKey: ['metrics'],
queryFn: fetchMetrics,
});
return {};
}
export default function Dashboard() {
// Use React Query in component
const { data: metrics } = useQuery({
queryKey: ['metrics'],
queryFn: fetchMetrics,
refetchInterval: 30_000, // Now we can poll!
});
return <div>Active Users: {metrics?.activeUsers}</div>;
}
Deploy this one change. Verify it works. Now that route gets all React Query benefits (caching, polling, invalidation) while keeping the fast initial load from the loader.
Step 4: Extract Shared Data to React Query
If user data appears in multiple route loaders, extract it:
Before:
// app/routes/posts._index.tsx
export async function loader({ request }) {
const user = await getUser(request); // Fetched here
const posts = await getPosts();
return { user, posts };
}
// app/routes/settings.tsx
export async function loader({ request }) {
const user = await getUser(request); // And here
const settings = await getSettings();
return { user, settings };
}
After:
// app/hooks/use-current-user.ts
export function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: async () => {
const response = await fetch('/api/me');
return response.json();
},
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
});
}
// app/routes/posts._index.tsx
export async function loader() {
const posts = await getPosts(); // Only fetch posts
return { posts };
}
export default function PostsIndex() {
const { data: user } = useCurrentUser(); // Get user from cache
const { posts } = useLoaderData<typeof loader>();
return <div>Welcome {user?.name}</div>;
}
Now user data fetches once, caches, and every component can access it. This is the biggest win when migrating.
Step 5: Convert Mutations to React Query
Replace action-based mutations with React Query mutations for better control:
Before:
export async function action({ request, params }) {
const formData = await request.formData();
await updatePost(params.id, formData);
return redirect(`/posts/${params.id}`);
}
After:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function EditPost() {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (data) => updatePost(postId, data),
onSuccess: () => {
// Invalidate all affected queries
queryClient.invalidateQueries({ queryKey: ['posts'] });
navigate(`/posts/${postId}`);
},
});
return <form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
updateMutation.mutate(Object.fromEntries(formData));
}} />;
}
Now you can do optimistic updates, show loading states, and invalidate queries across the entire app.
Step 6: Keep What Works
Not every route needs React Query. I have apps where:
- 80% of routes use loaders only (simple CRUD, settings pages, forms)
- 20% of routes use React Query (dashboards, shared data, real-time features)
This is fine. The hybrid approach lets you use the right tool for each route. Static content? Loader. Dynamic, shared, or real-time? React Query.
Common Migration Pitfalls
Don't immediately remove all loaders:
Loaders are still valuable for SSR and initial data. Keep them to pre-fill React Query cache.
Don't duplicate query logic:
Create shared query functions:
// app/queries/posts.ts
export const postQueries = {
all: () => ['posts'] as const,
lists: () => [...postQueries.all(), 'list'] as const,
list: (filters: string) => [...postQueries.lists(), { filters }] as const,
details: () => [...postQueries.all(), 'detail'] as const,
detail: (id: string) => [...postQueries.details(), id] as const,
};
export async function fetchPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}
// Use in both loaders and components
export async function loader({ params }) {
await queryClient.fetchQuery({
queryKey: postQueries.detail(params.id),
queryFn: () => fetchPost(params.id),
});
return {};
}
export default function Post() {
const params = useParams();
const { data: post } = useQuery({
queryKey: postQueries.detail(params.id!),
queryFn: () => fetchPost(params.id!),
});
}
This keeps query keys consistent and logic DRY.
Don't over-cache:
Set appropriate staleTime and cacheTime. Don't cache everything forever. Fresh data matters more than cache hits for critical information.
The migration doesn't have to happen all at once. Add React Query where it solves problems. Keep loaders where they work well. Ship incrementally.
What I'd Do Differently
I used to add React Query by default to every project. After working with React Router v7's framework mode for six months, I don't. The automatic revalidation after actions covers 80% of what I needed React Query for.
For new projects, I start with just loaders. When I find myself needing data in multiple places or wanting background updates, I add React Query. This keeps the initial bundle smaller and the mental model simpler.
The combination of server-side loaders for initial data and React Query for client-side updates is powerful when you need it. But don't reach for it until you actually need it.
I have a production app with 60+ routes using just loaders. It's a content management system where each route loads its own data. Fast, simple, maintainable. React Query would add complexity without benefit.
I have another production app that's a real-time dashboard. React Query is critical there. Shared data across widgets, background polling, optimistic updates when users interact. Loaders alone wouldn't work.
The right choice depends on your data patterns, not dogma about which tool is "better."