React Router Guide: Routing, Navigation, and Data Loading
React Router is the de facto standard for client-side routing in React applications. Version 6, released in 2021, introduced a completely redesigned API based on React hooks and a hierarchical route configuration that aligns with React’s component model. As Ryan Florence, co-creator of React Router, explained in the v6 announcement: “We built v6 to feel like it was always meant to be part of React.”
React Router v6 Architecture
React Router v6 shifts from a static route configuration to a dynamic, component-based approach. Routes are defined as components rendered inside a <Routes> parent, which automatically selects the best matching route:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users/:id" element={<UserProfile />} />
</Routes>Nested Routes
Nested routes are one of v6’s killer features. They mirror the component hierarchy and enable layout routes that wrap child content:
<Route path="dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="settings" element={<Settings />} />
</Route>The <DashboardLayout /> component renders an <Outlet /> where child route content appears. This eliminates the manual rendering of nested content that plagued v5 and earlier versions.
Route Matching Algorithm
React Router v6 uses a ranked route matching algorithm. It scores routes based on specificity: static segments, dynamic segments (:param), and wildcards (*). The most specific match wins. This approach, documented in the React Router v6 upgrade guide, eliminates the ambiguity of v5’s “first match wins” behavior.
Core Hooks
React Router v6 provides hooks for accessing routing state:
- useParams — Returns URL parameters from the current route
- useNavigate — Programmatic navigation function (replaces
useHistory) - useLocation — Returns the current location object
- useSearchParams — Read and modify query parameters (similar to
useStatefor the query string) - useOutlet — Returns the child route element for the current route
- useHref — Returns the resolved href for a given path
useNavigate
const navigate = useNavigate();
navigate('/dashboard', { replace: true });navigate accepts a relative or absolute path, a delta number (-1 for back), or a state object. The replace option replaces the current history entry instead of pushing a new one.
useSearchParams
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get('page') || '1';
setSearchParams({ page: '2' });This is particularly useful for filter and pagination UIs where query parameters should reflect the current view.
Data Loading with Loaders and Actions
React Router v6.4 introduced data APIs — loaders, actions, and fetchers — that integrate data fetching with routing. These APIs were extracted from Remix, the full-stack React framework created by the React Router team.
Loaders
A loader is an async function that provides data to a route before it renders:
<Route
path="users/:id"
element={<UserProfile />}
loader={({ params }) => fetch(`/api/users/${params.id}`)}
/>Access loaded data with useLoaderData:
function UserProfile() {
const user = useLoaderData();
return <div>{user.name}</div>;
---Loaders enable concurrent data fetching for all matched routes, eliminating the waterfall pattern common in useEffect-based data fetching. The React Router documentation explains that loaders run in parallel for sibling routes, and parent loaders run before their children.
Actions
Actions handle mutations (form submissions, deletions) and automatically revalidate loader data:
<Route
path="users/:id/edit"
element={<EditUser />}
action={async ({ request, params }) => {
const formData = await request.formData();
return updateUser(params.id, Object.fromEntries(formData));
}}
/>Use useActionData to access the result of the most recent action submission, and useNavigation to track the submission state.
Navigation Guards
React Router v6 provides <Navigate> for declarative redirects and the useBlocker hook (experimental) for preventing navigation. The shouldRevalidate option on loaders gives fine-grained control over when data refreshes.
Protected Routes
Implement authentication guards with a wrapper component:
function RequireAuth({ children }) {
const user = useAuth();
return user ? children : <Navigate to="/login" replace />;
---
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />Lazy Loading Routes
React Router v6 integrates with React.lazy for route-level code splitting. Each route’s component is loaded only when the user navigates to it:
const Dashboard = React.lazy(() => import('./routes/Dashboard'));
const Settings = React.lazy(() => import('./routes/Settings'));
function App() {
return (
<Routes>
<Route path="/dashboard" element={
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
} />
<Route path="/settings" element={
<Suspense fallback={<PageSkeleton />}>
<Settings />
</Suspense>
} />
</Routes>
);
---Route-level code splitting reduces the initial bundle size by only loading the components needed for the current page. This improves initial load time and can reduce time-to-interactive by 30-50% on slower networks. Combined with React Router’s data loaders, which can fetch data in parallel with the component code, users experience minimal loading time when navigating between routes.
Error Handling with Error Boundaries
React Router v6’s data router API integrates with error boundaries at the route level:
<Route
path="users/:id"
element={<UserProfile />}
errorElement={<ErrorBoundary />}
loader={({ params }) => fetchUser(params.id)}
/>Error elements replace the routing component when a loader or action throws an error. This provides granular error handling — a failed data fetch in one route does not crash the entire application, and each route can have its own error UI.
function ErrorBoundary() {
const error = useRouteError();
return <div>Something went wrong: {error.message}</div>;
---The useRouteError hook provides access to the thrown error for logging and user-friendly messages. Combine with isRouteErrorResponse to distinguish between application errors and 404/redirect responses.
Code Splitting with React Router
Combine React Router with React.lazy for route-based code splitting:
const Dashboard = React.lazy(() => import('./Dashboard'));
<Route path="/dashboard" element={
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
--- />;Route-based code splitting reduces initial bundle size by loading components only when their route is visited. The React documentation on code splitting recommends this as a best practice for production applications.
Link and Navigation Components
React Router provides <Link> for declarative navigation and <NavLink> for active link styling:
<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'active' : ''}>
Dashboard
</NavLink><Link> renders an <a> element with the correct href, which ensures right-click and open-in-new-tab work correctly — a benefit over programmatic navigation. The prefetch prop on <Link> (when used with data routers) prefetches the linked route’s data for instant navigation.
Relative links in React Router v6 resolve relative to the current route, not the URL. This means <Link to="edit"> inside a /users/123 route navigates to /users/123/edit, while <Link to="../settings"> navigates to /settings.
Scroll Restoration
React Router v6 provides scroll restoration by default, restoring scroll position when navigating with the back/forward buttons. For custom scroll behavior, use useScrollRestoration (experimental) or manually manage scroll positions with useEffect and useLocation:
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => window.scrollTo(0, 0), [pathname]);
return null;
---For list/detail patterns where you want to preserve scroll position in a list when navigating to a detail page and back, React Router’s data loading integrates with the browser’s scroll cache when using ScrollRestoration from the data router APIs.
URL Parameter Patterns
React Router supports several parameter patterns:
- Required params:
:id— must be present in the URL - Optional params:
:page?— made optional with a?suffix (not supported directly; use default values viauseParams) - Splat params:
*— matches everything after the route path - Pattern matching: Use parameters inside
useParamsand combine withuseSearchParamsfor complex filtering - Search parameter synchronization: Keep URL query parameters in sync with component state to enable shareable links and browser history navigation for filter, search, and pagination state
React Performance Best Practices
Performance optimization in React requires understanding when and why components re-render. React re-renders a component when its state changes, its parent re-renders, or the context it consumes changes. Unnecessary re-renders are the most common performance issue. Use React.memo to prevent re-renders when props have not changed — it performs a shallow comparison of props and skips rendering if they are identical. The useMemo hook memoizes expensive computation results, recalculating only when dependencies change. The useCallback hook memoizes function references, preventing child components from re-rendering due to new function references on every parent render. For lists, ensure each item has a stable, unique key prop — using array indices as keys causes bugs when items are reordered. Virtualization libraries like react-window and react-virtuoso render only visible items in long lists, dramatically reducing DOM nodes. Code splitting with React.lazy and Suspense loads components on demand, reducing initial bundle size. Profile your application with React DevTools Profiler before optimizing — measuring identifies actual bottlenecks that are worth fixing.
Testing React Components Effectively
React Testing Library encourages testing from the user’s perspective. Write tests that verify behavior, not implementation details. Use getByRole, getByLabelText, and getByText queries to find elements the way users do — by accessibility attributes and visible text. Avoid testing internal state or component internals; instead, test what the user sees and does. For async operations, use waitFor and findBy queries that retry until the element appears. Mock external dependencies at the network level using MSW (Mock Service Worker) rather than mocking imported modules. This creates more realistic tests that verify your integration with actual API contracts.
FAQ
What happened to Switch in React Router v6?
<Switch> was replaced by <Routes>. <Routes> uses the new ranked route matching algorithm and automatically selects the best match, so you no longer need to order routes from most to least specific.
How do I pass props to a route component?
Use the element prop to render your component directly with any props:
<Route path="/users" element={<UserList currentUser={user} />} />Can I use React Router outside the browser?
Yes. React Router v6 includes createMemoryRouter for testing and non-browser environments. The createStaticRouter API supports server-side rendering.
What is the difference between React Router and Remix?
Remix is a full-stack web framework built on top of React Router. It uses React Router’s data APIs (loaders, actions) as its foundation and adds server-side rendering, file-based routing, and deployment adapters. React Router is the client-side routing library that powers Remix.
Conclusion
React Router v6 is a complete reimagining of client-side routing that aligns with React’s component model and hooks API. The addition of loaders and actions in v6.4 brings data fetching into the routing layer, reducing boilerplate and improving performance through parallel loading. The official React Router documentation at reactrouter.com provides excellent tutorials and API references. For managing form state alongside routing, see React Forms Guide, and for authentication patterns that integrate with protected routes, see React Authentication.
For a comprehensive overview, read our article on Next Js Guide.
For a comprehensive overview, read our article on React Authentication.