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

Published: (February 26, 2026 at 07:11 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

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?
Combining React Query with React Router loaders makes that possible.

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

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) => ({
  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 (
    <>
      <h2>{pokemon.name}</h2>
      <p>Height: {pokemon.height}</p>
      <p>Weight: {pokemon.weight}</p>
    </>
  );
};

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?

ApproachWhat Happens
useQuery onlyComponent mounts → starts fetching → shows loading → shows data
ensureQueryData in loader + useQueryData 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 (e.g., 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.
  • Return only the params from the loader — not the data itself. The data lives in React Query’s cache.

Your pages now load instantly on navigation, and React Query handles caching, background refetching, and stale data for free.

0 views
Back to Blog

Related posts

Read more »

Did Your Project Really Need Next.js?

Introduction Recently, I’ve been seeing more and more teams migrating projects from Next.js to TanStack. Cases like Inngest, which reduced local dev time by 83...