Data Loading and Cache

Load API data predictably, avoid flicker, and keep cache behavior explicit.

Use shared queryOptions definitions with TanStack Query, then preload data at the right boundary (client loader vs server loader).

export const usersOptions = queryOptions({
  queryKey: ["users", "list"],
  queryFn: fetchUsers,
});
export const userOptions = (id: string) =>
  queryOptions({
    queryKey: ["users", id],
    queryFn: () => fetchUser(id),
  });
export const userOffersOptions = (id: string) =>
  queryOptions({
    queryKey: ["users", id, "offers"],
    queryFn: () => fetchUserOffers(id),
  });

Client-side data handling (useQueries, clientLoader, mutations)

Use clientLoader to warm cache on client transitions, and useQueries when a page depends on multiple independent queries.

import type { ClientLoaderFunctionArgs } from "react-router";

export async function clientLoader({ params }: ClientLoaderFunctionArgs) {
  const id = params.id!;
  await queryClient.ensureQueryData(userOptions(id));
  void queryClient.prefetchQuery(usersOptions);
  return null;
}

Component usage with useQueries:

const UserPage = ({ id }: { id: string }) => {
  const [{ data: user }, { data: offers }] = useQueries({
    queries: [userOptions(id), userOffersOptions(id)],
  });
  // ...
};

Mutations and invalidation:

const updateUserMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: async (_result, variables, _context) => {
    await Promise.all([
      queryClient.invalidateQueries({ queryKey: usersOptions.queryKey }),
      queryClient.invalidateQueries({
        queryKey: userOptions(variables.id).queryKey,
      }),
    ]);
  },
});

Server-side data handling (loader)

Use server loader for SSR-critical data (SEO/public pages, first render correctness), fetch directly in the loader, and read the result via loader hooks in components.

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

export async function loader({ params }: LoaderFunctionArgs) {
  const id = params.id!;
  const user = await fetchUser(id);
  const offers = await fetchUserOffers(id);
  return { user, offers };
}

export function UserRoutePage() {
  const { user, offers } = useLoaderData() as Awaited<ReturnType<typeof loader>>;
  return <UserView user={user} offers={offers} />;
}

Alternatives and when to choose them

  • Loader-only strategy when app is simple and query cache is unnecessary.
  • BFF (backend for frontend) aggregation endpoints when frontend must reduce request fan-out.

Implementation checklist

  • Centralize query key factories.
  • Document stale time/refetch defaults.
  • Use optimistic updates only with rollback strategy.

Common pitfalls

  • Inconsistent query keys causing stale UI.
  • Mutation success states ending before visible data revalidation finishes.

On this page