State Management
React Router fundamentally changes how you think about client-side state. Its loader/action pattern handles the most common use case for global state management libraries, such as Redux, React Query, or Zustand: fetching server data and keeping it synchronized after mutations. Since React Router manages this natively, you typically don’t need a separate library for such remote state.
What this means in practice: A server-centric model replaces the classic client-side fetch-on-mount, store-in-state, manually-invalidate cycle:
- Route enters,
loader()runs, and then data flows into the component. For example, data flows via theuseLoaderData()hook. - User submits form,
action()runs, and then React Router revalidates all loaders automatically.
| State Type | Description | Native Tool |
|---|---|---|
| Server / Remote State | Data fetched from a server | loader + useLoaderData |
| Mutations | Write operations to the server | action + Form |
| Component-Local Mutations | Mutations without navigation | useFetcher |
| Cross-Route Data | Accessing another route’s loader data | useRouteLoaderData |
| Middleware Context | Request-scoped data injected before loaders | context.set / context.get |
| Navigation State | Current navigation in progress | useNavigation |
| URL State | State encoded in the URL | useSearchParams, useParams |
| Persistent State | State persisted across requests via cookies | createCookie, createCookieSessionStorage |
| Optimistic State | Temporary UI before server confirms | fetcher.formData, useNavigation, useOptimistic |
| Error State | Loader/action errors per route | useRouteError |
| React Primitives | Native React state management primitives | useState, useMemo, Context API, and so on |
This is the central state management pattern in React Router. It replaces client-side data fetching patterns, such as fetch-on-mount and manual cache invalidation, with a declarative, server-centric model.
- A
loaderruns on the server before the route renders. Its return value becomes the component’s data viauseLoaderData(). - An
actionhandles mutations. After it completes, React Router automatically revalidates all active loaders. React Router manages the data lifecycle entirely.
For loader, data classification (critical vs. non-critical), streaming patterns, action processing, and resource routes, see Data Fetching.
The <Form> component serializes form state into FormData and submits it to the route’s action. After the action completes, React Router revalidates all active loaders automatically. This is the primary mutation pattern when the operation should trigger a navigation or a full-page state update.
For action arguments, validation patterns, and examples, see Data Fetching.
useFetcher triggers loaders and actions without causing navigation. It gives each component its own mutation lifecycle, making it suitable for inline edits, autosave, toggling, and any mutation that shouldn’t change the URL.
From a state management perspective, each fetcher exposes its own lifecycle. fetcher.state exposes the mutation lifecycle while fetcher.data holds the action’s return value. This enables per-component feedback (for example, inline validation errors) without global state.
For fetcher usage patterns and resource routes, see Data Fetching. For visual feedback patterns based on fetcher.state, see Loading States.
useRouteLoaderData accesses the loader data of any currently active route by its route ID. This avoids prop-drilling when a child route needs data that a parent or sibling route already loaded.
A common use case is accessing root-level data, such as session, locale, or site configuration, from deeply nested routes without passing it through every intermediate layout. The route ID passed to useRouteLoaderData must match the route’s id as defined in the route configuration. The hook returns undefined if the target route isn’t currently active.
Middleware context is React Router’s mechanism for request-scoped dependency injection. Middlewares run in a pipeline before loaders and actions, populating a typed context object with data that all downstream loaders and actions can access. This eliminates the need for loaders to independently resolve cross-cutting concerns such as sessions, app configuration, or API clients.
For middleware definition, pipeline ordering, and writing middleware functions, see Data Fetching.
Middleware context isn’t the React Context API. While middleware context carries server-side data, it serves a distinct role from loaders. Middleware context operates in the request lifecycle (before render), is consumed by loaders and actions, and is scoped to a single request. The React Context API operates in the render lifecycle and is consumed by components. Data from middleware context typically reaches components via the loader return value and useLoaderData.
useNavigation exposes the global navigation state, useful for pending UI during route transitions.
navigation.formData is available during form submissions, enabling optimistic UI based on the submitted values before the server responds. For loading state patterns in detail, see Loading States.
The URL is a state container. Search parameters, path parameters, and the location object are reactive state sources that survive page refreshes, are shareable via links, and integrate with the browser’s history stack.
useSearchParams reads and writes URL query parameters. It replaces useState for state that should be reflected in the URL, such as filters, pagination, sort order, or modal visibility.
When to use useSearchParams over useState: If the state should survive a page refresh, be shareable via URL, or be accessible to loaders (via request.url), it belongs in search params.
useParams returns the dynamic path parameters for the current route. It’s read-only and reactive, the component re-renders when the route changes.
Sometimes, state must survive page refreshes and revisits without belonging to the URL. Examples include user preferences (theme, locale, dismissed banners), shopping cart identifiers, and authentication tokens. Cookies and server sessions are React Router’s native mechanism for this kind of persistent state.
Unlike useState (transient) or useSearchParams (URL-visible), cookies travel with every HTTP request and are available in loaders and actions on the server. This behavior makes cookies a single source of truth that requires no client-side synchronization, avoids localStorage SSR issues, and works even before scripts load.
React Router provides createCookie for simple key-value cookies and createCookieSessionStorage for structured, typed session data. Both integrate directly with the loader/action lifecycle: cookies are read via request.headers.get('Cookie') and written via Set-Cookie response headers.
| Criterion | Cookies (createCookie) | Sessions (createCookieSessionStorage) | URL (useSearchParams) |
|---|---|---|---|
| Survives page refresh | Yes | Yes | Yes |
| Shareable via link | No | No | Yes |
| Available in loaders | Yes | Yes | Yes (via request.url) |
| Works without JS | Yes | Yes | Only with <Form> |
| Structured / typed data | Manual parsing | Built-in typed API | String key-value pairs |
| Security (httpOnly, signed) | Yes | Yes (with secrets) | No (user-visible) |
| Best for | Simple preferences, flags | Auth, multi-field user state | Filters, pagination, sort |
For details on cookies and sessions APIs, see Data Fetching.
Optimistic UI updates the interface immediately, before the server confirms the mutation, and rolls back if the operation fails. React Router and React offer complementary approaches, each suited to a different kind of state.
useFetcher exposes the submitted formData while an action is in flight. This enables optimistic reads directly from the pending submission without additional state management. This pattern requires no extra hooks and works with any version of React. It’s the idiomatic React Router approach for simple optimistic updates derived from the submitted form values.
When state is serialized into URL search parameters and updates trigger a navigate() call, useNavigation() provides a navigation-aware optimistic path. While a navigation is pending, navigation.location holds the target Location object. Reading the intended query parameters from that target reflects the user’s action immediately, without any local state to manage.
This works because navigation.location is only defined while a navigation is in flight. It holds the destination URL, so the component can derive the optimistic state from the search params the router is navigating to. When the navigation completes, navigation.location becomes undefined in the same render cycle that delivers the fresh loader props. The ternary falls through to the new server data automatically.
Key properties of this pattern:
- No local state: Nothing to sync, reset, or reconcile.
- Survives the pending phase: The optimistic value is read from the URL the router is navigating to, so it persists for the entire loader fetch.
- Self-cleaning: Once the navigation settles, the component naturally falls back to the new props.
useNavigation() reflects any in-flight navigation, not just the one the current component triggered. In practice this is acceptable: if a different navigation starts, the entire page is about to change anyway. For mutations that don’t change the URL, prefer fetcher.formData or useOptimistic instead.
useOptimistic applies a temporary state update that React automatically reverts once the underlying async operation settles. It’s suited for more complex optimistic transformations where the optimistic state isn’t a direct read from formData or the URL, for example, inserting a new item into a list.
React Router treats errors from loaders and actions as a first-class route state. When a loader or action throws an error, React Router catches the error and renders the nearest ErrorBoundary export in the route hierarchy.
useRouteError accesses the thrown error inside an ErrorBoundary. isRouteErrorResponse distinguishes between Response-based errors (thrown via throw new Response(...) or data() with a non-2xx status in a loader or action) and unexpected exceptions. This behavior enables differentiated error UIs, for example, a styled 404 page vs. a generic error fallback.
The sections above cover state that React Router manages as part of the routing and data lifecycle. What remains to complete the picture, is state that lives entirely on the client, managed by React’s own primitives. These are the built-in hooks and patterns for local component state, derived values, cross-component sharing, and integration with external stores.
The baseline primitive for synchronous, isolated state within a single component. Use the useState hook when exactly one component owns the state and you don’t need to share it.
The useReducer hook is preferable over useState when state transitions are complex, interdependent, or benefit from explicit action semantics.
Derived state is any value you compute from existing state or props rather than store independently. It’s a pure function of other state and has no source of truth of its own.
Key rule: Don’t store derived values in useState. This practice creates a secondary source of truth and forces manual synchronization, which is a common source of bugs.
useMemo caches the result of an expensive computation and only recalculates it when one of its dependencies changes, preventing unnecessary recalculation on every render. Use this for expensive transformations, such as sorting, filtering, and aggregating, over arrays or objects.
When to use useMemo: For cheap computations, a plain variable in the render body is sufficient. Reserve useMemo for expensive computations or when referential stability is required (for example, as a dependency in useEffect or memo).
useCallback memoizes a function reference. Without it, every render creates a new function instance, which breaks referential equality checks in child components wrapped with memo.
When useCallback isn’t needed: If memo doesn’t wrap the receiving component, useCallback has no observable effect and only adds noise. Apply it specifically when passing callbacks to memoized components or as dependencies of other hooks (useEffect, useMemo) where referential stability matters.
Context propagates state through the React tree without prop drilling. It’s appropriate for state that changes infrequently and must be accessible across many components.
For request-scoped data that loaders and actions need (session, auth, and config), see Middleware Context. The React Context API is for UI state shared across the component tree during rendering.
Good use cases: Locale, user preferences, and feature flags.
Poor use cases: High-frequency updates (for example, mouse position, real-time data), large state objects where many consumers only need a small slice.
Every context value change re-renders all its consumers, regardless of whether they use the changed part of the value. Mitigate this by splitting contexts. A component using, for example, only PreferencesContext doesn’t re-render when user changes.
When a context holds multiple values but a consumer only needs one slice, splitting contexts isn’t always practical. Domain stores often have many fields and many consumers each needing different slices. The selector pattern addresses this demand by accepting a selector function and returning only the selected slice. Combined with useSyncExternalStore, subscriptions are per-slice: a consumer re-renders only when its slice changes.
The context holds a reference to an external store, not the state itself. The hook drives the subscription via useSyncExternalStore:
Consumers call the hook once per slice. The badge re-renders only when isOpen flips. The form re-renders only when config changes:
React’s useContext doesn’t support selectors natively—every consumer re-renders on any context value change. A selector hook that only wraps useContext is a convention and doesn’t, by itself, skip renders. Actual render memoization requires either useSyncExternalStore, as shown earlier, which is tearing-safe and concurrent-mode correct, or React.memo on the consuming component, combined with a referentially stable selector result. For high-frequency updates on large domain stores, prefer the useSyncExternalStore approach.
useSyncExternalStore integrates state that lives outside the React tree—vanilla JavaScript objects, browser APIs, or custom event emitters. It is concurrent-mode safe and does not require a Provider.
Every component calling useCart() subscribes automatically. No Provider, no wrapping component required.
The same pattern makes any browser API reactive:
The same pattern applies to matchMedia, localStorage, BroadcastChannel, visibilitychange, and similar APIs.
getSnapshot must return a referentially stable value when state hasn’t changed. Returning a new object on every call causes an infinite render loop.
If you need derived/selected state, cache the result inside the store or use useRef for memoization.
| Criterion | useSyncExternalStore | Context API |
|---|---|---|
| State location | Outside React (module scope) | Inside React tree |
| Re-render granularity | Only subscribed components | All consumers of the context |
| Provider required | No | Yes |
| Concurrent Mode safe | Yes (tearing-safe by design) | Partially |
| Best for | High-frequency or global state | Infrequently changing, tree-local state |
Reads a Promise directly inside a component and integrates with Suspense. Eliminates the need for useEffect-based data fetching and loading state management. For declarative loading patterns with use() and Suspense, see Loading States.
useTransition marks state updates as non-urgent, keeping the UI responsive during heavy re-renders.
useDeferredValue defers the propagation of a rapidly changing value, similar to debouncing but integrated with React’s concurrent scheduler.
useActionState manages the full lifecycle of an async action: pending status, return value, and error state. It replaces manual useState and useTransition wiring around actions. Listed here for completeness as a React primitive.
In the React Router framework, useActionState has no practical relevance. Route action + Form and useFetcher provide the same pending or error lifecycle and additionally trigger automatic loader revalidation after mutations. useActionState bypasses React Router’s data lifecycle entirely, loaders aren’t revalidated after a useActionState action completes. Even for purely client-side logic, a clientAction with useFetcher is preferable because it keeps the component within React Router’s consistent data flow and avoids introducing a parallel mutation pattern.