Data note
25

Data note · Published Nov 10, 2025

I Default to React Router Loaders Until Caching Hurts

Six months with React Router loaders changed my default. I add React Query only when repeated fetching, shared data, or polling gives me a reason.

A field-guide drawing of a camping match tin
In this article5 sections

I used to install React Query at the start of every React project. Six months with React Router v7 framework mode changed that habit.

I now start with loaders. They cover the boring majority of my routes without another cache, provider, or collection of query keys. React Query comes back when I can name the problem it will solve.

Two applications pushed me toward that rule. One is a content management system with more than 60 routes. Loaders are enough there. The other is a real-time dashboard where the same data appears in several widgets and needs to update without navigation. React Query earns its place in that one.

The CMS did not need another cache

A loader keeps the request next to the route that needs it:

import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.id);

  if (!post) {
    throw new Response("Not Found", { status: 404 });
  }

  return { post };
}

export default function PostRoute() {
  const { post } = useLoaderData<typeof loader>();
  return <article>{post.title}</article>;
}

The server fetches the data before rendering the route. The component receives a typed result, and the initial HTML already contains the content.

Actions cover the other half of the CMS. A form updates a record, React Router runs the relevant loaders again, and the page receives fresh data. I do not have to choose a cache key or remember which query to invalidate.

That pattern handles posts, settings, user profiles, and most admin screens. Each route owns its data. Users move from one page to another, make a change, and continue. Adding React Query would give me another state system without removing any work I was actually doing.

I have more than 60 routes running this way. I kept waiting for the architecture to become too simple. It did not.

The dashboard made the missing cache obvious

The dashboard behaved differently. A posts endpoint fired 15 times during two minutes of ordinary navigation. Same 200 posts. Same 50KB response. The loader was doing exactly what I asked every time the route appeared.

Adding React Query with a five-minute stale time cut those 15 requests to one. Server CPU dropped 40% during the same flow.

That was not a theoretical caching benefit. I could see the duplicate requests in the network panel before the change and their absence afterward.

The dashboard also polls for new metrics. Loaders run around navigation and form actions; they do not keep a widget fresh while someone leaves the page open. I could have built the interval, deduplication, retry behavior, and cleanup myself. I had already installed the library that does those things.

const { data } = useQuery({
  queryKey: ["metrics"],
  queryFn: fetchMetrics,
  staleTime: 30_000,
  refetchInterval: 60_000,
});

That query has a reason to exist. “We use React Query” is not the reason.

Three failures bring React Query back

I add React Query when I hit one of three failures.

The same data keeps crossing route boundaries. A current user appears in the header, sidebar, and settings page. Fetching it independently in each loader wastes requests. Pushing it through root-loader context makes invalidation clumsy. One query cache gives every component the same result.

The data changes without navigation. Dashboards, order status, and background jobs need polling or refetch-on-focus. A route loader has already finished by then.

Waiting for a mutation makes the interaction feel broken. A like button or toggle should respond before the round trip finishes. React Query gives me optimistic state and a defined rollback path.

If none of those things are happening, I leave the dependency out.

The network panel settles the argument

I no longer decide this from an architecture diagram. I use the application and watch its requests.

Repeated identical responses tell me a cache would help. Several components inventing their own copy of the same server data tell me ownership has spread too far. Hand-written polling code tells me I am rebuilding a library.

The absence of those problems matters too. A page that loads once, submits a form, and revalidates does not need a client cache because another project needed one.

My current split looks like this:

What the application doesWhat I start with
Page-based CRUD and settingsReact Router loaders and actions
Initial server render followed by occasional changesLoaders first
Shared data across many componentsReact Query
Polling or refetch-on-focusReact Query
Optimistic interactionsReact Query

The hybrid approach is fine. Some routes in an application can stay loader-only while the dashboard uses React Query. I do not need to migrate every route to justify one useful cache.

What changed my default

React Query is still the better tool for client-side server state. I stopped treating every server response as client-side server state by default.

The CMS taught me how far loaders can go. The dashboard taught me where they stop. I start with the smaller system now and let a visible failure earn the second one.

My default is loaders. The network panel gets to overrule me.

One quick signal

Did this earn your time?

What was missing?

Thanks. That gives me something concrete to check.