State Management in React Router: Patterns and Libraries
React Router is the de facto routing library for React applications, but routing and state management are deeply intertwined concerns. Every URL change is, in essence, a state transition β and every piece of state you keep in your components raises the question: should this live in the URL instead? This tutorial explores the patterns, built-in APIs, and third-party libraries that help you manage state effectively alongside React Router.
What Is Router State Management?
Router state management refers to the strategies and tools used to keep application state synchronized with the URL, navigation history, and route-level data. React Router exposes several primitives for this: URL parameters, query strings, location state, loader data, and the history stack. Combined with general-purpose state libraries like Redux, Zustand, or Jotai, you can build applications where state is predictable, shareable, and resilient to refreshes.
At its core, the question is always: where should this state live? The answer determines whether your users can bookmark a view, share a link, or hit the back button and land where they expect.
Why It Matters
- Shareability: State encoded in the URL can be shared via a link without additional context.
- Refresh resilience: URL-derived state survives a full page reload; component state does not.
- Back/forward navigation: When state lives in the URL, the browser's history stack becomes a natural undo mechanism.
- Server rendering: Loaders and URL params can be resolved on the server before React hydrates.
- Testability: Routes with explicit state are easier to test than components relying on buried context.
Built-in State Mechanisms in React Router
1. URL Parameters
Path parameters are the most fundamental form of router state. They identify a resource and are ideal for IDs, slugs, and other values that define which thing the user is looking at.
import { Routes, Route, useParams } from 'react-router-dom';
function ProductDetail() {
const { productId } = useParams();
return <h1>Viewing product {productId}</h1>;
}
function App() {
return (
<Routes>
<Route path="/products/:productId" element={<ProductDetail />} />
</Routes>
);
}
2. Query Parameters
Query strings are perfect for filters, sorting, pagination, and search terms. They keep secondary state visible and bookmarkable without polluting the path.
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') ?? '1');
const sort = searchParams.get('sort') ?? 'newest';
function nextPage() {
setSearchParams(prev => {
prev.set('page', String(page + 1));
return prev;
});
}
return (
<div>
<p>Page {page}, sorted by {sort}</p>
<button onClick={nextPage}>Next</button>
</div>
);
}
3. Location State
Location state is ephemeral state attached to a navigation entry. It does not appear in the URL, so it is lost on refresh, but it is perfect for passing context between routes β for example, where the user came from.
import { useNavigate, useLocation } from 'react-router-dom';
function ListPage() {
const navigate = useNavigate();
return (
<button onClick={() => navigate('/details/42', { state: { from: 'list' } })}>
Open details
</button>
);
}
function DetailsPage() {
const location = useLocation();
const state = location.state; // { from: 'list' } or null on refresh
return <p>Arrived from: {state?.from ?? 'unknown'}</p>;
}
4. Loaders and Action Data
React Router v6.4+ introduced data routers with loaders and actions. These provide a powerful pattern: fetch and mutate data at the route level, then access it in your component without prop drilling or external stores.
import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router-dom';
async function productLoader({ params }) {
const res = await fetch(`/api/products/${params.productId}`);
if (!res.ok) throw new Response('Not found', { status: 404 });
return res.json();
}
function Product() {
const product = useLoaderData();
return <h1>{product.name}</h1>;
}
const router = createBrowserRouter([
{
path: '/products/:productId',
element: <Product />,
loader: productLoader,
errorElement: <p>Something went wrong.</p>,
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
Patterns for Combining Router and Global State
Pattern 1: URL as the Source of Truth
The most robust pattern is to treat the URL as the canonical state for anything the user might want to bookmark or share. Global stores then hold only derived or ephemeral data.
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
function SearchResults() {
const [params] = useSearchParams();
const query = params.get('q') ?? '';
const { data, isLoading } = useQuery({
queryKey: ['search', query],
queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
enabled: query.length > 0,
});
if (isLoading) return <p>Loadingβ¦</p>;
return <ul>{data?.map(item => <li key={item.id}>{item.title}</li>)}</ul>;
}
Here the URL drives the query key, so navigating back and forth automatically refetches the right data.
Pattern 2: Syncing Global State to the URL
Sometimes you have UI state in a store that should be reflected in the URL. A common example is a sidebar filter that should survive a refresh. You can sync the two with an effect.
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useStore } from './store';
function useFilterSync() {
const [searchParams, setSearchParams] = useSearchParams();
const filter = useStore(s => s.filter);
const setFilter = useStore(s => s.setFilter);
// Store -> URL
useEffect(() => {
setSearchParams(prev => {
if (filter) prev.set('filter', filter);
else prev.delete('filter');
return prev;
}, { replace: true });
}, [filter, setSearchParams]);
// URL -> Store (on mount and back/forward)
useEffect(() => {
const urlFilter = searchParams.get('filter');
if (urlFilter && urlFilter !== filter) setFilter(urlFilter);
}, [searchParams]);
}
Use { replace: true } when you do not want every keystroke to create a new history entry.
Pattern 3: Route-Level State with Context
For state that is scoped to a subtree of routes β such as a wizard's multi-step form β use a context provider in the layout route. This keeps the state local to the routes that need it and resets cleanly when the user navigates away.
import { createContext, useContext, useState } from 'react';
import { Outlet } from 'react-router-dom';
const WizardContext = createContext(null);
export function useWizard() {
const ctx = useContext(WizardContext);
if (!ctx) throw new Error('useWizard must be used within WizardLayout');
return ctx;
}
export function WizardLayout() {
const [step, setStep] = useState(0);
const [data, setData] = useState({});
return (
<WizardContext.Provider value={{ step, setStep, data, setData }}>
<Outlet />
</WizardContext.Provider>
);
}
Popular Libraries for Router-Aware State
Zustand
Zustand is a lightweight store that pairs well with React Router. Because it has no provider, you can read from it inside loaders, actions, and components alike.
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// Inside a loader
export async function protectedLoader() {
const user = useAuthStore.getState().user;
if (!user) throw new Response('', { status: 401 });
return null;
}
Redux Toolkit
For larger applications, Redux Toolkit's rtk-query integrates cleanly with React Router loaders. You can prefetch data in a loader and let the cache hydrate the component.
import { apiSlice } from './apiSlice';
export async function userLoader({ params }, store) {
await store.dispatch(apiSlice.endpoints.getUser.initiate(params.id));
return null;
}
TanStack Query
TanStack Query is arguably the best companion to React Router's data APIs. Use loaders to trigger prefetches, and components to subscribe to the cache. This gives you automatic refetching, stale-while-revalidate, and optimistic updates without duplicating state.
export async function loader({ params, queryClient }) {
await queryClient.prefetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetch(`/api/users/${params.id}`).then(r => r.json()),
});
return null;
}
Best Practices
- Prefer the URL for shareable state. If a user would want to send a link to a colleague, that state belongs in the URL.
- Keep ephemeral UI state out of the URL. Modal open/close, hover state, and accordion expansion usually do not belong in the address bar.
- Use
replacefor transient updates. Avoid filling the history stack with every keystroke in a search box. - Validate URL state. Treat query params as untrusted input β coerce types, clamp ranges, and provide defaults.
- Co-locate data fetching with routes. Loaders keep components focused on rendering and make loading states declarative.
- Reset state on navigation. Use route keys or layout providers to ensure stale state does not leak between unrelated views.
- Don't duplicate state. If a value is in the URL, do not also keep it in a global store as the source of truth β derive instead.
- Handle errors at the route level. Use
errorElementto catch loader and render errors in a consistent place.
Putting It All Together
A typical mature application combines several of these techniques: URL params and query strings for shareable state, loaders for server data, TanStack Query for caching and refetching, and a small Zustand store for ephemeral UI concerns like theme or sidebar visibility. The key discipline is deciding, for each piece of state, where it belongs β and being consistent about that decision across the codebase.
// App.jsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore } from './store/auth';
import ProductList from './routes/ProductList';
import ProductDetail from './routes/ProductDetail';
const queryClient = new QueryClient();
async function requireAuth() {
if (!useAuthStore.getState().user) {
throw new Response('Unauthorized', { status: 401 });
}
return null;
}
const router = createBrowserRouter([
{
path: '/',
element: <ProductList />,
},
{
path: '/products/:productId',
loader: requireAuth,
element: <ProductDetail />,
errorElement: <p>Could not load product.</p>,
},
]);
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
Conclusion
State management in React Router is less about picking a single library and more about choosing the right home for each piece of state. The URL is a remarkably capable store β it is shareable, persistent, and integrates with the browser's native navigation. Loaders and actions extend that model to server data, while libraries like Zustand, Redux Toolkit, and TanStack Query fill in the gaps for ephemeral and cached state. By treating the URL as the source of truth for shareable state, co-locating data fetching with routes, and reserving global stores for genuinely cross-cutting concerns, you can build React applications that are predictable, refresh-safe, and a pleasure to navigate. The patterns in this tutorial are not mutually exclusive β most production apps use several of them together β but the discipline of asking "where should this state live?" for every new feature is what separates a maintainable routing architecture from a tangled one.