The Problem
When you navigate to a page, there's usually a delay while data is being fetched. The user sees a loading spinner, and the content pops in after the request finishes. Not great.
What if the data was already there when the page loads?
That's exactly what combining React Query with React Router loaders gives you.
The Idea in Plain English
- Loader runs before the component mounts (React Router calls it on navigation).
- Inside the loader, we ask React Query: "Do you already have this data cached?"
- Yes → Use it instantly. No network request.
- No → Fetch it now, wait for it, then cache it.
- When the component finally mounts, it calls
useQuerywith the same query. Since the data is already cached, it renders immediately — no loading state.
The key method is queryClient.ensureQueryData(queryOptions). Think of it as: "Make sure this data exists — get it from cache or fetch it."
Step-by-Step Example: A Simple Pokémon Page
Let's build a page that shows Pokémon details. When you navigate to /pokemon/pikachu, the data is already loaded.
1. Set Up the Query
// pages/Pokemon.jsx
import { useQuery } from '@tanstack/react-query';
import { useLoaderData } from 'react-router-dom';
import axios from 'axios';
// A function that returns the query config (key + fetch function).
// We reuse this in BOTH the loader and the component.
const pokemonQuery = (name) => {
return {
queryKey: ['pokemon', name],
queryFn: async () => {
const response = await axios.get(
`https://pokeapi.co/api/v2/pokemon/${name}`
);
return response.data;
},
};
};
2. Create the Loader
// The loader receives queryClient from the router setup (see step 4).
// It runs BEFORE the component mounts.
export const loader = (queryClient) => {
return async ({ params }) => {
const { name } = params;
// ensureQueryData checks the cache first:
// - cached? → returns it instantly
// - not cached? → fetches, caches, and returns it
await queryClient.ensureQueryData(pokemonQuery(name));
// We only return the param — the actual data lives in React Query's cache
return { name };
};
};
3. Build the Component
const Pokemon = () => {
// Get the param that the loader returned
const { name } = useLoaderData();
// useQuery uses the SAME query config as the loader.
// Since ensureQueryData already cached it, this renders instantly.
const { data: pokemon } = useQuery(pokemonQuery(name));
return (
<div>
<h1>{pokemon.name}</h1>
<img src={pokemon.sprites.front_default} alt={pokemon.name} />
<p>Height: {pokemon.height}</p>
<p>Weight: {pokemon.weight}</p>
</div>
);
};
export default Pokemon;
4. Wire It Up in the Router
// App.jsx
import {
createBrowserRouter,
RouterProvider,
} from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Pokemon, { loader as pokemonLoader } from './pages/Pokemon';
const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: '/pokemon/:name',
element: <Pokemon />,
// Pass queryClient into the loader
loader: pokemonLoader(queryClient),
},
]);
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App;
How It All Flows
User clicks link to /pokemon/pikachu
│
▼
Router calls loader BEFORE mounting the component
│
▼
loader calls: await queryClient.ensureQueryData(pokemonQuery("pikachu"))
│
├── Cache HIT? → Returns cached data instantly (no fetch)
│
└── Cache MISS? → Fetches from API, caches result, then returns
│
▼
Component mounts → useQuery(pokemonQuery("pikachu"))
│
▼
Data is already in cache → Renders IMMEDIATELY (no loading spinner)
Why Not Just Use useQuery Alone?
You totally can use useQuery by itself. But here's the difference:
| Approach | What Happens |
|---|---|
useQuery only |
Component mounts → starts fetching → shows loading → shows data |
ensureQueryData in loader + useQuery
|
Data fetched before mount → component renders with data instantly |
The loader approach gives a smoother, faster UX — especially on page navigations.
The Key Takeaways
-
ensureQueryData= "If cached, use cache. If not, fetch and cache it." - Create a shared query config function (like
pokemonQuery) and use it in both the loader and the component. - The loader pre-fills the cache so
useQueryin the component finds the data immediately. - You return only the params from the loader — not the data itself. The data lives in React Query's cache.
That's it. Your pages now load instantly on navigation, and React Query handles caching, background refetching, and stale data for free.
Top comments (0)