How React Router's Internal Store Manages Route Data: A Deep Dive into RouterState
React Router maintains route data in a private RouterState object that lives inside each Router instance, updated immutably during every navigation and exposed to components through hooks like useLoaderData() and useFetcher().
Unlike Redux or Zustand, React Router does not expose a public "store" for application state. Instead, the remix-run/react-router codebase implements a specialized internal state container that manages loader results, action submissions, fetcher states, and navigation errors. This RouterState object serves as the single source of truth for all routing concerns.
What Is React Router's Internal Store?
React Router's internal store is the RouterState interface defined in packages/react-router/lib/router/router.ts. Each Router instance created via createBrowserRouter() or createMemoryRouter() maintains its own private state property that conforms to this interface.
The store is intentionally isolated from React's component state to prevent unnecessary re-renders during complex navigation transitions. Instead, the router uses a subscription model where RouterProvider and individual hooks register listeners via router.subscribe(), receiving updates only when updateState() publishes a new state object.
The RouterState Data Structure
The internal store organizes route-related data into distinct maps and properties, each handling a specific aspect of the navigation lifecycle.
| State Property | Type | Purpose |
|---|---|---|
loaderData |
RouteData |
Stores successful loader results keyed by route ID |
actionData |
RouteData | null |
Contains the most recent action submission result |
errors |
RouteData | null |
Maps error boundaries to thrown errors |
fetchers |
Map<string, Fetcher> |
Active fetcher instances and their states |
blockers |
Map<string, Blocker> |
Navigation blockers preventing history changes |
Loader Data Storage
Loader results live in state.loaderData, a plain JavaScript object where keys correspond to route IDs defined in your route configuration. When a navigation initiates, the router does not immediately clear existing loader data. Instead, mergeLoaderData() (defined at lines 13-44 in router.ts) combines new results with existing state, preserving data for routes that did not participate in the current navigation.
Action Data and Errors
Action submissions follow a similar pattern but use separate storage. The actionData property receives results via getActionDataForCommit() during completeNavigation(). Errors are stored in the errors map, with processRouteLoaderData() (lines 53-66) bubbling the first encountered error up to the nearest error boundary route.
How Route Data Flows Through the Store
React Router updates its internal store through a deterministic, immutable pipeline during every navigation.
1. Navigation Initialization
When navigate() is called or a POP event fires, the router creates a new Location object and invokes startNavigation(). This initializes the pending navigation state but does not yet modify loaderData or actionData.
2. Loader and Action Execution
The router constructs a DataStrategy that invokes route loaders and actions. Results are collected as DataResult objects in a temporary results map. This phase runs asynchronously, allowing parallel data fetching across route branches.
3. Processing Results with processRouteLoaderData()
Once loaders complete, processRouteLoaderData() processes the results map. This function (located at lines 53-66 in packages/react-router/lib/router/router.ts) performs three critical operations:
- Builds a fresh
loaderDataobject with successful results keyed by route ID - Identifies routes that threw errors and marks them with
ResetLoaderDataSymbolto clear stale data - Constructs an
errorsmap that bubbles the first error to the appropriate error boundary
// Simplified representation from router.ts
function processRouteLoaderData(
matches: AgnosticDataRouteMatch[],
results: Record<string, DataResult>,
pendingActionResult?: PendingActionResult
): {
loaderData: RouterState["loaderData"];
errors: RouterState["errors"] | null;
} {
// Processes each match, extracts data or errors,
// and determines which routes need data reset
}
4. Merging State with mergeLoaderData()
Rather than replacing loaderData entirely, completeNavigation() calls mergeLoaderData() (lines 13-44) to combine new results with existing state. This preserves data for routes that did not reload during the navigation, enabling persistent UI state across transitions.
// From router.ts lines 13-44
function mergeLoaderData(
loaderData: RouteData,
newLoaderData: RouteData,
matches: AgnosticDataRouteMatch[],
errors: RouteData | null | undefined
): RouteData {
// Merges objects, handles ResetLoaderDataSymbol,
// and ensures error boundaries receive undefined data
}
5. Notifying Subscribers via updateState()
Finally, updateState() (lines 55-63) replaces the old state object with the new one and notifies all subscribers. This immutable update pattern ensures React components receive consistent snapshots of route data.
// router.ts lines 55-63
function updateState(newState: Partial<RouterState>, opts = {}) {
state = { ...state, ...newState };
// Notify subscribers like RouterProvider
subscribers.forEach(subscriber => subscriber(state, opts));
}
Loader Data vs. Fetcher Data
While both loaders and fetchers interact with the internal store, they use distinct storage mechanisms.
Loader Data resides in state.loaderData and is accessed via the useLoaderData() hook defined in packages/react-router/lib/hooks.tsx. This data is route-scoped and persists across navigations for routes that do not reload.
Fetcher Data maintains independent state within state.fetchers, a Map<string, Fetcher> where each entry contains its own state, data, formData, and submission metadata. The useFetcher() hook reads from and writes to this map, allowing components to load or submit data without changing the URL or affecting global loader data.
Error Handling and Data Reset Semantics
When a route loader throws an error, React Router's store implements specific cleanup logic to prevent stale data from persisting.
The processRouteLoaderData() function marks failed routes with ResetLoaderDataSymbol during error processing. When mergeLoaderData() encounters this symbol, it explicitly sets the route's entry to undefined, ensuring that UI components do not display outdated information after an error boundary catches the exception.
Errors themselves are stored in state.errors as a map keyed by route ID, with findNearestBoundary() determining which route component should render the error boundary based on the route hierarchy.
Summary
- React Router's internal store is the private
RouterStateobject owned by eachRouterinstance, defined inpackages/react-router/lib/router.ts. - Loader data persists in
state.loaderDataand merges incrementally viamergeLoaderData()to preserve data for routes that do not reload. - Action results populate
state.actionDataduringcompleteNavigation(), while errors bubble throughstate.errorsusing boundary detection. - Fetchers maintain isolated state in
state.fetchers, aMapallowing parallel data interactions without affecting global route state. - The store updates immutably through
updateState(), notifying subscribers likeRouterProviderafter every navigation completes.
Frequently Asked Questions
Does React Router use Redux or another external state management library?
No. React Router implements its own specialized state container called RouterState that lives inside each router instance. This internal store manages only routing-related data such as loader results, action submissions, and navigation errors, eliminating the need for external dependencies like Redux.
How does React Router prevent stale loader data from appearing after navigation?
The router uses mergeLoaderData() to combine new loader results with existing state rather than replacing the entire object. This function preserves data for routes that did not participate in the current navigation. When a loader throws an error, processRouteLoaderData() marks that route with ResetLoaderDataSymbol, causing mergeLoaderData() to explicitly clear the stale entry.
What is the difference between loader data and fetcher data in the internal store?
Loader data resides in state.loaderData, a plain object keyed by route ID that persists across navigations and is accessed via useLoaderData(). Fetcher data lives in state.fetchers, a Map<string, Fetcher> where each fetcher maintains its own independent state, data, and form submission metadata, allowing components to interact with data without changing the URL or affecting global loader state.
Where does error handling occur in React Router's state management?
Errors are processed during the processRouteLoaderData() phase, which constructs an errors map that bubbles the first encountered error up to the nearest error boundary route. These errors are stored in state.errors and cleared during completeNavigation() when the error is resolved or the user navigates away.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →