Client-Side Data Loading
React Router provides several ways to load and manage data entirely on the client, enabling progressive enhancement, caching strategies, and hybrid server/client data patterns.Client Loaders
UseclientLoader to fetch data only on the client:
import type { Route } from "./+types/products";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const product = await fetch(`/api/products/${params.id}`).then((r) => r.json());
return { product };
}
export default function Product({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>{loaderData.product.name}</h1>
<p>{loaderData.product.description}</p>
</div>
);
}
Combining Server and Client Loaders
Call server loader from client for hybrid loading:export async function loader({ params }: Route.LoaderArgs) {
// Runs on server during SSR
const product = await db.product.findUnique({
where: { id: params.id },
});
return { product };
}
export async function clientLoader({
params,
serverLoader,
}: Route.ClientLoaderArgs) {
// Check cache first
const cached = cache.get(`product:${params.id}`);
if (cached) return cached;
// Fall back to server loader
const data = await serverLoader();
cache.set(`product:${params.id}`, data);
return data;
}
Client-Side Caching
const cache = new Map<string, any>();
export async function clientLoader({
params,
request,
}: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const cacheKey = `${url.pathname}${url.search}`;
// Return cached data if available
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
// Fetch and cache
const data = await fetch(`/api/data/${params.id}`).then((r) => r.json());
cache.set(cacheKey, data);
return data;
}
// Clear cache on actions
export async function clientAction({ request }: Route.ClientActionArgs) {
cache.clear();
const formData = await request.formData();
return await fetch("/api/data", {
method: "POST",
body: formData,
}).then((r) => r.json());
}
Time-Based Cache
interface CacheEntry<T> {
data: T;
timestamp: number;
}
const cache = new Map<string, CacheEntry<any>>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const cacheKey = `user:${params.id}`;
const cached = cache.get(cacheKey);
// Return cached data if still fresh
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
// Fetch fresh data
const data = await fetch(`/api/users/${params.id}`).then((r) => r.json());
cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return data;
}
Local Storage
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// Try local storage first
const cached = localStorage.getItem(`draft:${params.id}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from server
const data = await fetch(`/api/drafts/${params.id}`).then((r) => r.json());
return data;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const id = formData.get("id");
// Save to local storage
const draft = {
id,
title: formData.get("title"),
content: formData.get("content"),
};
localStorage.setItem(`draft:${id}`, JSON.stringify(draft));
return { success: true };
}
IndexedDB
import { openDB } from "idb";
const dbPromise = openDB("my-app", 1, {
upgrade(db) {
db.createObjectStore("products");
},
});
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const db = await dbPromise;
// Try IndexedDB first
const cached = await db.get("products", params.id);
if (cached) return { product: cached };
// Fetch and store
const product = await fetch(`/api/products/${params.id}`).then((r) => r.json());
await db.put("products", product, params.id);
return { product };
}
Prefetching Data
import { prefetchRoute } from "react-router";
export default function ProductList({ products }) {
return (
<ul>
{products.map((product) => (
<li
key={product.id}
onMouseEnter={() => {
// Prefetch on hover
prefetchRoute(`/products/${product.id}`);
}}
>
<Link to={`/products/${product.id}`}>
{product.name}
</Link>
</li>
))}
</ul>
);
}
Client Actions
Handle mutations entirely on the client:export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
// Call API directly
const response = await fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
price: formData.get("price"),
}),
});
if (!response.ok) {
return { error: "Failed to create product" };
}
return await response.json();
}
Optimistic Client Updates
const cache = new Map<string, any>();
export async function clientAction({
request,
params,
}: Route.ClientActionArgs) {
const formData = await request.formData();
// Optimistically update cache
const optimisticData = {
id: params.id,
name: formData.get("name"),
updatedAt: new Date().toISOString(),
};
cache.set(`item:${params.id}`, optimisticData);
try {
// Send to server
const result = await fetch(`/api/items/${params.id}`, {
method: "PUT",
body: formData,
}).then((r) => r.json());
// Update cache with server data
cache.set(`item:${params.id}`, result);
return result;
} catch (error) {
// Rollback on error
cache.delete(`item:${params.id}`);
throw error;
}
}
Background Sync
const pendingSync = new Map<string, any>();
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
// Queue for background sync
const syncId = crypto.randomUUID();
pendingSync.set(syncId, data);
// Try to sync immediately
try {
await syncData(syncId, data);
pendingSync.delete(syncId);
} catch (error) {
// Will retry later via service worker
console.log("Queued for background sync");
}
return { success: true, queued: pendingSync.has(syncId) };
}
async function syncData(id: string, data: any) {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Sync failed");
return response.json();
}
Progressive Enhancement
// Works with server loader as fallback
export async function loader({ params }: Route.LoaderArgs) {
// Server-side loading for initial page load and no-JS
const data = await db.getData(params.id);
return { data };
}
export async function clientLoader({
params,
serverLoader,
}: Route.ClientLoaderArgs) {
// Enhanced client-side loading for subsequent navigations
// Check if we're online
if (!navigator.onLine) {
// Use cached data when offline
const cached = await getCachedData(params.id);
if (cached) return { data: cached };
}
// Use server loader as fallback
return serverLoader();
}
Hydration
Control hydration behavior:import type { Route } from "./+types/route";
export async function loader() {
return { serverData: "from server" };
}
export async function clientLoader({
serverLoader,
}: Route.ClientLoaderArgs) {
// On first load, use server data
// On client navigation, fetch fresh data
const serverData = await serverLoader();
return {
...serverData,
clientData: "enhanced on client",
};
}
// Skip hydration to always run clientLoader
clientLoader.hydrate = false;
React Query Integration
import { useQuery } from "@tanstack/react-query";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// Return empty data for initial render
return { product: null };
}
export default function Product({ loaderData }: Route.ComponentProps) {
const { data: product } = useQuery({
queryKey: ["product", loaderData.productId],
queryFn: () => fetch(`/api/products/${loaderData.productId}`).then((r) => r.json()),
});
if (!product) return <div>Loading...</div>;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
SWR Integration
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function User({ params }: { params: { id: string } }) {
const { data, error } = useSWR(`/api/users/${params.id}`, fetcher);
if (error) return <div>Failed to load</div>;
if (!data) return <div>Loading...</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
Best Practices
Cache Invalidation
// Clear cache after mutations
export async function clientAction({ request }: Route.ClientActionArgs) {
const result = await fetch("/api/data", {
method: "POST",
body: await request.formData(),
});
// Invalidate related caches
cache.delete("list");
cache.delete("stats");
return result.json();
}
Error Boundaries
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
try {
const data = await fetch(`/api/data/${params.id}`).then((r) => r.json());
return { data };
} catch (error) {
throw new Response("Failed to load data", { status: 500 });
}
}
export function ErrorBoundary() {
const error = useRouteError();
return <div>Error: {error.message}</div>;
}
Loading States
import { useNavigation } from "react-router";
export default function Component({ loaderData }: Route.ComponentProps) {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return (
<div>
{isLoading && <Spinner />}
<div className={isLoading ? "opacity-50" : ""}>
{loaderData.content}
</div>
</div>
);
}
Related
- Loaders - Server-side data loading
- Fetchers - Load data without navigation
- Revalidation - Keep data fresh