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 loaderData object with successful results keyed by route ID
  • Identifies routes that threw errors and marks them with ResetLoaderDataSymbol to clear stale data
  • Constructs an errors map 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 RouterState object owned by each Router instance, defined in packages/react-router/lib/router.ts.
  • Loader data persists in state.loaderData and merges incrementally via mergeLoaderData() to preserve data for routes that do not reload.
  • Action results populate state.actionData during completeNavigation(), while errors bubble through state.errors using boundary detection.
  • Fetchers maintain isolated state in state.fetchers, a Map allowing parallel data interactions without affecting global route state.
  • The store updates immutably through updateState(), notifying subscribers like RouterProvider after 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →