DEV Community

Mohamed Idris
Mohamed Idris

Posted on

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

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

  1. Loader runs before the component mounts (React Router calls it on navigation).
  2. 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.
  3. When the component finally mounts, it calls useQuery with 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;
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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 };
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 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.

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)