← Back to DevBytes

State Management in React Router: Patterns and Libraries

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

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

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.

πŸ›  Tools from DevBytes

Inventory Tracker Pro β€” Excel inventory system, low-stock alerts Β· $19
AI Dev Kit for Mac β€” local AI dev environment templates Β· $9.99
KeyMapper for Mac β€” custom keyboard shortcut toolkit Β· $7.99

← Back to all articles