Loaders fetch data for a route before the UI renders. This page explains when loaders execute, how results are cached, and how different types of navigation affect loader behavior.
A loader is a function on a route definition that receives the route params, a Request, and an AbortSignal. It can return any value — typically a Promise from a fetch call:
import { route } from "@funstack/router";
const userRoute = route({
path: "/users/:id",
loader: async ({ params, request, signal }) => {
const res = await fetch(`/api/users/${params.id}`, { signal });
return res.json();
},
component: UserPage,
});The component receives the loader’s return value as the data prop. For async loaders this is a Promise, which you unwrap with React’s use() hook inside a Suspense boundary:
import { use, Suspense } from "react";
function UserPage({ data }: { data: Promise<User> }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserDetail data={data} />
</Suspense>
);
}
function UserDetail({ data }: { data: Promise<User> }) {
const user = use(data);
return <h1>{user.name}</h1>;
}Loaders run at two points in the lifecycle:
navigate()), the Router matches the destination URL and executes loaders for the matched routes.In both cases, all loaders in the matched route stack (parent and child) run in parallel. The navigation completes once every loader’s Promise has resolved.
Loader results are cached using the navigation entry ID from the Navigation API. Each time you navigate to a new URL, the browser creates a new navigation entry with a unique ID. The Router uses this ID as the cache key, so:
This design ensures that loaders run exactly once per navigation while preventing unnecessary re-fetches during React re-renders.
Different types of navigation have different effects on whether loaders run:
A push navigation (the default when clicking a link or calling navigate()) creates a new navigation entry. Since the entry is new, loaders always execute. A replace navigation behaves the same way — it creates a new entry that replaces the current one, so loaders execute fresh.
When the user goes back or forward in history, the browser revisits an existing navigation entry. Because the entry ID is the same as when the page was originally visited, the cached loader results are returned without re-executing the loaders. This makes back/forward navigation instant.
A reload navigation stays on the same navigation entry, but the Router generates a fresh cache key so that all loaders re-execute. This is useful when you want to refresh data without navigating away from the current page.
You can trigger a reload programmatically using the Navigation API’s navigation.reload() method:
function RefreshButton() {
return (
<button onClick={() => navigation.reload()}>
Refresh Data
</button>
);
}During a reload, the old cached data remains available for the pending UI. Because the Router wraps navigations in a React transition, the previous UI stays on screen while the new data loads. Once the new loaders resolve, the UI updates. This means users see the existing content while the refresh is in progress, rather than a blank screen or loading spinner.
Consecutive reloads work correctly — each reload increments an internal counter to produce a unique cache key, and stale caches are pruned automatically.
When a <form method="post"> is submitted, the Router runs the matched route’s action first, then clears the loader cache for the current entry and re-executes all loaders. The action’s return value is passed to each loader as actionResult. See the Form Actions page for details.
When routes are nested, each route in the matched stack can define its own loader. All loaders in the stack execute in parallel, and each component receives its own loader’s result:
const routes = [
route({
path: "/dashboard",
loader: () => fetchDashboardLayout(),
component: DashboardLayout,
children: [
route({
path: "/stats",
loader: () => fetchStats(),
component: StatsPage,
}),
],
}),
];
// When navigating to /dashboard/stats:
// → fetchDashboardLayout() and fetchStats() run in parallel
// → DashboardLayout receives the layout data
// → StatsPage receives the stats dataOn reload, all loaders in the matched stack re-execute, not just the deepest one.
Cached loader results are automatically cleaned up when a navigation entry is disposed. The browser disposes entries when they are removed from the history stack (for example, when the user navigates forward from a point in the middle of the history stack, the entries ahead are discarded). The Router listens for these dispose events and removes the corresponding cached data.
When a loader throws, the error bubbles to the nearest Error Boundary above that route. Synchronous loader errors are re-thrown during route rendering, while rejected loader Promises surface when use(data) runs.
For the recommended boundary placement, how to reset it on navigation with useLocation().entryId, and examples for nested layouts, see Error Handling.
| Navigation type | Loaders run? | Why |
|---|---|---|
| Push / Replace | Yes | New navigation entry, no cache |
| Traverse (Back / Forward) | No | Existing entry, cached results returned |
| Reload | Yes | Fresh cache key generated |
| Form submission (POST) | Yes | Cache cleared after action runs |