Tools: How to Use React Query with React Router Loaders (Pre-fetch & Cache Data)

Tools: How to Use React Query with React Router Loaders (Pre-fetch & Cache Data)

Source: Dev.to

The Problem ## The Idea in Plain English ## Step-by-Step Example: A Simple Pokémon Page ## 1. Set Up the Query ## 2. Create the Loader ## 3. Build the Component ## 4. Wire It Up in the Router ## How It All Flows ## Why Not Just Use useQuery Alone? ## The Key Takeaways 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 key method is queryClient.ensureQueryData(queryOptions). Think of it as: "Make sure this data exists — get it from cache or fetch it." Let's build a page that shows Pokémon details. When you navigate to /pokemon/pikachu, the data is already loaded. You totally can use useQuery by itself. But here's the difference: The loader approach gives a smoother, faster UX — especially on page navigations. That's it. Your pages now load instantly on navigation, and React Query handles caching, background refetching, and stale data for free. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: // 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; }, }; }; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // 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; }, }; }; COMMAND_BLOCK: // 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; }, }; }; COMMAND_BLOCK: // 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 }; }; }; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // 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 }; }; }; COMMAND_BLOCK: // 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 }; }; }; COMMAND_BLOCK: 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; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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; COMMAND_BLOCK: 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; CODE_BLOCK: // 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; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // 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; CODE_BLOCK: // 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; CODE_BLOCK: 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) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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) CODE_BLOCK: 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) - 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. - Yes → Use it instantly. No network request. - No → Fetch it now, wait for it, then cache it. - When the component finally mounts, it calls useQuery with the same query. Since the data is already cached, it renders immediately — no loading state. - Yes → Use it instantly. No network request. - No → Fetch it now, wait for it, then cache it. - 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 useQuery in 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.