Used

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.

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.

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.

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.

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>
  );
}
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.