How toUse TanStack Query to Share Data between React Router Loaders

Let's say you have two routes that match the same URL, e.g. app/routes/dashboard and app/routes/dashboard._index, and both need to display user statistics for a dashboard.

The traditional way is that you get the data on both loaders, even if that means you fetch it two times.

app/lib/analytics.server.ts
import { z } from "zod"; export const UserStatsSchema = z.object({ totalUsers: z.number(), activeUsers: z.number(), newUsersToday: z.number(), completionRate: z.number(), }); export async function fetchUserStats() { let response = await fetch("https://api.example.com/analytics/users"); return UserStatsSchema.promise().parse(response.json()); }

But we could do something better, we can implement an in-memory server-side cache to share data.

import { cache } from "~/cache.server";

export async function fetchUserStats() {
  if (cache.has("user-stats")) return cache.get("user-stats");
  let response = await fetch("https://api.example.com/analytics/users");

  let stats = await UserStatsSchema.promise().parse(response.json());

  cache.put("user-stats", stats);

  return stats;
}

The problem is that if the two loaders trigger fetchUserStats at the same time both will get cache.has("user-stats") as false.

So we also need a way to batch and dedupe requests.

Enters TanStack Query.

This library has a QueryClient object that can cache the data of the queries for us, and if the same query is executed twice it will only run it once.

And a great thing about that library is that like there's a React version there's also @tanstack/query-core which is framework agnostic, so we can use it fully server-side without using the React hooks.

Create a Query Context

First, we need to create a context to share the QueryClient instance across our routes using React Router's context API.

app/lib/query-context.ts
import { createContext } from "react-router"; import type { QueryClient } from "@tanstack/query-core"; export const queryClientContext = createContext<QueryClient>();

Add QueryClient Middleware to Root Route

Add the middleware directly to your root route so it's available to all child routes.

app/root.tsx
import { QueryClient } from "@tanstack/query-core"; import { queryClientContext } from "./lib/query-context"; import type { Route } from "react-router"; import { Outlet } from "react-router"; export const middleware: Route.MiddlewareFunction[] = [ async ({ context }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { // Cache data indefinitely for the duration of the request staleTime: Number.POSITIVE_INFINITY, }, }, }); context.set(queryClientContext, queryClient); }, ];

Use the QueryClient in Parent and Child Routes

Now we can use the QueryClient in routes that match the same URL. Let's create a parent route and a child route that both need the same user statistics for the dashboard.

app/routes/dashboard.tsx
import type { Route } from "react-router"; import { Outlet, useLoaderData } from "react-router"; import { queryClientContext } from "~/lib/query-context"; import { fetchUserStats } from "~/lib/analytics.server"; export async function loader({ context }: Route.LoaderArgs) { const queryClient = context.get(queryClientContext); const userStats = await queryClient.fetchQuery({ queryKey: ["user-stats"], queryFn: fetchUserStats, }); return { userStats }; } export default function Dashboard() { const { userStats } = useLoaderData<typeof loader>(); return ( <div> <h1>Analytics Dashboard</h1> <div className="stats-overview"> <div>Total Users: {userStats.totalUsers}</div> <div>Active Users: {userStats.activeUsers}</div> </div> <Outlet /> </div> ); }
app/routes/dashboard._index.tsx
import type { Route } from "react-router"; import { useLoaderData } from "react-router"; import { queryClientContext } from "~/lib/query-context"; import { fetchUserStats } from "~/lib/analytics.server"; export async function loader({ context }: Route.LoaderArgs) { const queryClient = context.get(queryClientContext); const userStats = await queryClient.fetchQuery({ queryKey: ["user-stats"], queryFn: fetchUserStats, }); return { userStats }; } export default function DashboardIndex() { const { userStats } = useLoaderData<typeof loader>(); return ( <div> <h2>Detailed Analytics</h2> <div className="detailed-stats"> <p>New users today: {userStats.newUsersToday}</p> <p>Completion rate: {userStats.completionRate}%</p> <div className="chart"> {/* Chart component would go here */} </div> </div> </div> ); }

With this setup, when a user visits /dashboard, both the parent dashboard.tsx and child dashboard._index.tsx loaders will run in parallel. Since they both use the same queryKey of ["user-stats"], TanStack Query will only execute the fetchUserStats function once and share the cached result between both routes.