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.