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 the useLoaderData() hook.
User submits form, action() runs, and then React Router revalidates all loaders automatically.
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 loader runs on the server before the route renders. Its return value becomes the component’s data via useLoaderData().
An action handles mutations. After it completes, React Router automatically revalidates all active loaders. React Router manages the data lifecycle entirely.
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.
Component-Local Mutations via useFetcher()
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.
Cross-Route Data via useRouteLoaderData()
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.
1// routes/root.tsx2export async function loader(){3 const user = await getUser();4 const locale = await getLocale();5 return{user, locale};6}78// routes/products/product-detail.tsx9import{useRouteLoaderData}from "react-router";1011export default function ProductDetail(){12 const rootData = useRouteLoaderData<typeof rootLoader>"root";13 return<p>Welcome, {rootData?.user?.name}</p>;14}
Middleware Context
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.
Note
Navigation State
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.
URL State
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()
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.
1import{useParams}from "react-router";23function ProductDetail(){4 const{productId} = useParams();5 // productId is derived from the URL, e.g., /products/:productId6}
Persistent State via Cookies and Sessions
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.
When to Use Cookies vs. Sessions vs. URL State
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 State
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.
Via fetcher.formData (React Router)
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.
Via useNavigation() (React Router, URL-Driven State)
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.
1import{useNavigation}from "react-router";23function Filters({items}){4 const navigation = useNavigation();56 // While a navigation is pending, read from the target URL.7 // Once it settles, fall back to the server-provided props.8 const effectiveItems = navigation.location9 ? new URLSearchParams(navigation.location.search).getAll("item")10 : items;1112 return(13<ul>14{effectiveItems.map((item)=>(15<li key={item}>{item}</li>16))}17</ul>18);19}
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.
Note
Via useOptimistic() (React)
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.
Local UI State
useState()
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.
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).
1// Plain variable: fine for cheap ops2const fullName = `${firstName} ${lastName}`;34// useMemo: justified for expensive ops or stable references5const filteredItems = useMemo(()=> largeList.filter((item)=> item.active), [largeList]);
useCallback()
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.
1const handleDelete = useCallback((id: string)=>{2 setProducts((prev)=> prev.filter((p)=> p.id !== id));3}, []); // stable reference — no unnecessary re-renders in React.memo children
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.
Shared UI State
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.
Note
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.
Basic Setup
1// preferences-context.tsx2type PreferencesState = {3 density: "compact" | "comfortable",4 setDensity: (d: "compact" | "comfortable")=> void,5};6const PreferencesContext = (createContext<PreferencesState) | (null>null);78export function PreferencesProvider({children}: {children: React.ReactNode}){9 const[density, setDensity] = (useState<"compact") | ("comfortable">"comfortable");1011 return(12<PreferencesContext.Provider value={{density, setDensity}}>13{children}14</PreferencesContext.Provider>15);16}1718export function usePreferences(){19 const ctx = useContext(PreferencesContext);20 if(!ctx){21 throw new Error("usePreferences must be used within PreferencesProvider");22}23 return ctx;24}
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.
Note
1// Instead of one large context:2<AppContext.Provider value={{user, preferences, locale}} />34// Split into focused contexts:5<UserContext.Provider value={user} />6<PreferencesContext.Provider value={preferences} />7<LocaleContext.Provider value={locale} />
Context Selector Pattern
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:
1const StoreLocatorContext = createContext<StoreApi<StoreLocatorState> | null>(null);23export function useStoreLocator<T>(selector: (state: StoreLocatorState)=> T): T{4 const store = useContext(StoreLocatorContext);5 if(!store){6 throw new Error('useStoreLocator must be used within StoreLocatorProvider');7}8 const getSnapshot = useCallback(()=> selector(store.getState()), [store, selector]);9 return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);10}
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.
Note
Global Client State via useSyncExternalStore()
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.
Building a Minimal Store
1// store.ts2type Listener = ()=> void;34function createStore<T>(initialState: T){5 let state = initialState;6 const listeners = new Set<Listener>();78 return{9 getSnapshot:()=> state,10 subscribe:(listener: Listener)=>{11 listeners.add(listener);12 return()=> listeners.delete(listener);13},14 setState:(updater: (prev: T)=> T)=>{15 state = updater(state);16 listeners.forEach((fn)=> fn());17},18};19}2021export const cartStore = createStore({items:[]as CartItem[]});
1// useCart.ts2import{useSyncExternalStore}from "react";3import{cartStore}from "./store";45export function useCart(){6 const state = useSyncExternalStore(cartStore.subscribe, cartStore.getSnapshot);78 return{9 items: state.items,10 addItem:(item: CartItem)=> cartStore.setState((s)=>({items:[...s.items, item]})),11};12}
Every component calling useCart() subscribes automatically. No Provider, no wrapping component required.
The same pattern applies to matchMedia, localStorage, BroadcastChannel, visibilitychange, and similar APIs.
Critical: getSnapshot Stability
getSnapshot must return a referentially stable value when state hasn’t changed. Returning a new object on every call causes an infinite render loop.
1// WRONG — new object reference on every call2getSnapshot: ()=>({ ...state});34// CORRECT — same reference when state is unchanged5getSnapshot: ()=> state;
If you need derived/selected state, cache the result inside the store or use useRef for memoization.
Comparison: useSyncExternalStore() vs. Context API
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
Additional State Primitives
use(Promise)
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.
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.