React Router is the standard routing library for React. It enables navigation between views, URL parameter handling, and keeps your UI in sync with the URL — all without page reloads.Real-world analogy: Think of React Router like a TV remote. In a traditional website, switching channels (pages) means turning off the TV, walking to the store to buy a new one, and turning it on again (a full page reload). With client-side routing, you press a button on the remote and the channel changes instantly — the TV (your browser) stays on, your app state is preserved, and only the content on screen swaps out.
// App.jsximport { Routes, Route, Link } from 'react-router-dom';import Home from './pages/Home';import About from './pages/About';import Contact from './pages/Contact';import NotFound from './pages/NotFound';function App() { return ( <div> {/* Navigation -- use Link instead of <a> tags. <a> causes a full page reload; Link uses the History API to update the URL without reloading, keeping app state intact. */} <nav> <Link to="/">Home</Link> <Link to="/about">About</Link> <Link to="/contact">Contact</Link> </nav> {/* Route Definitions -- React Router matches the current URL against these paths and renders the matching element. The path="*" wildcard catches any URL that does not match a defined route, acting as a 404 page. */} <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> <Route path="/contact" element={<Contact />} /> <Route path="*" element={<NotFound />} /> </Routes> </div> );}
Common pitfall — using <a href> instead of <Link to>: A regular <a> tag causes the browser to make a full server request, which destroys all your React state and re-mounts the entire app. Always use React Router’s Link or NavLink for internal navigation.
import { useParams } from 'react-router-dom';function UserProfile() { // useParams returns an object whose keys match the :param names // in the route definition. For path="/users/:userId", // this destructures to the actual value in the URL. const { userId } = useParams(); const [user, setUser] = useState(null); useEffect(() => { // Re-fetch when userId changes (user navigates to a different profile). // Without [userId] in the dependency array, navigating from // /users/1 to /users/2 would show stale data. fetch(`/api/users/${userId}`) .then(res => res.json()) .then(setUser); }, [userId]); if (!user) return <Loading />; return ( <div> <h1>{user.name}</h1> <p>User ID: {userId}</p> </div> );}
import { useNavigate } from 'react-router-dom';function LoginForm() { const navigate = useNavigate(); const [error, setError] = useState(''); const handleSubmit = async (e) => { e.preventDefault(); try { await login(credentials); // Navigate to dashboard -- pushes a new entry onto the history stack, // so the user can press the back button to return here. navigate('/dashboard'); // replace: true -- replaces the current history entry instead of // pushing a new one. Use this after login so the user cannot // press "back" to return to the login form. navigate('/dashboard', { replace: true }); // Relative navigation -- go back one step in browser history. // Equivalent to the browser's back button. navigate(-1); // Pass state -- ephemeral data attached to the navigation. // Useful for showing flash messages ("You just logged in!") // without polluting the URL. navigate('/dashboard', { state: { from: 'login' } }); } catch (err) { setError('Login failed'); } }; return (/* form */);}
Analogy: A protected route is like a security checkpoint at an airport. If you have a valid boarding pass (auth token), you walk through to your gate (the protected page). If not, you are redirected to the ticketing counter (the login page), and the system remembers which gate you were trying to reach so it can send you there after you get your pass.
import { Navigate, Outlet, useLocation } from 'react-router-dom';import { useAuth } from './contexts/AuthContext';function ProtectedRoute() { const { user, loading } = useAuth(); const location = useLocation(); // Show a spinner while the auth check is in progress. // Without this, users see a flash of the login page on every // refresh before the token is read from storage. if (loading) { return <LoadingSpinner />; } if (!user) { // Save the attempted URL in navigation state so the login page // can redirect back here after successful authentication. // `replace` prevents the login redirect from appearing in // the browser history stack. return <Navigate to="/login" state={{ from: location }} replace />; } // <Outlet /> renders whatever child route matched. // This is what makes the route a "layout route" -- it wraps // its children with the auth check without needing to modify // each protected page individually. return <Outlet />;}
Pitfall 1 — Stale data after route parameter changes: If you fetch data in a useEffect based on a URL parameter, make sure the parameter is in the dependency array. Otherwise, navigating from /users/1 to /users/2 will not re-fetch because the component stays mounted — React Router reuses the same component instance for the same route pattern.
function UserProfile() { const { userId } = useParams(); useEffect(() => { // If userId is missing from the dependency array, this // only runs on mount. Navigating to a different user // shows stale data from the first user. fetchUser(userId); }, [userId]); // Always include route params here}
Pitfall 2 — Missing key on route-level components: When the same component renders for different route params (e.g., /posts/1 and /posts/2), React reuses the component instance. If you need a full remount (reset form state, scroll to top, re-run all effects), add a key tied to the param:
<Route path="/posts/:postId" element={<PostEditor key={postId} />} // Forces a full unmount/remount when postId changes/>
Pitfall 3 — Broken back button with replace: Using navigate('/path', { replace: true }) everywhere makes the back button useless. Only use replace when going back to the previous page would be harmful (e.g., after login, after a form submission that should not be repeated).
How does client-side routing work under the hood? What browser APIs does React Router use, and what happens to app state during navigation?
Strong Answer:
Client-side routing uses the History API — window.history.pushState and the popstate event — to change the URL without triggering a full page reload. When you click a Link component, React Router calls history.pushState() to update the URL bar, then matches the new URL against your route definitions and renders the matching component. No HTTP request is sent to the server.The JavaScript bundle stays loaded, React’s component tree stays mounted (except for components that unmount due to route changes), and all client-side state is preserved. The popstate event fires when the user clicks the browser’s back/forward buttons. React Router listens for this and re-runs route matching.BrowserRouter uses clean URLs like /dashboard/settings. This requires server configuration: every URL must return the same index.html, because if a user refreshes on /dashboard/settings, the server receives that path and needs to serve the SPA’s entry point. Without this catch-all redirect, the server returns a 404. This is the most common deployment issue with SPAs.HashRouter uses the URL hash (/#/dashboard/settings) to avoid this problem because everything after # is not sent to the server. But hash URLs are ugly and not SEO-friendly.Follow-up: How would you implement code splitting per route, and what are the UX considerations for the loading state?Use React.lazy() with dynamic imports for each route component. Wrap the Routes in a Suspense boundary with a fallback loading UI. When the user navigates for the first time, the chunk is fetched, the Suspense fallback shows, and once loaded, the component renders.For instant-feeling navigation, prefetch route chunks on link hover so the chunk loads during the hover. For nested routes, place Suspense boundaries at each layout level so that navigating between sibling routes only shows the fallback in the content area, not the entire page.
Design a protected route system that handles authentication, role-based access, and redirect-after-login. What are the edge cases?
Strong Answer:
The architecture has three layers. First, a PrivateRoute wrapper that checks useAuth(). If not authenticated, it redirects to /login saving the attempted URL in location state. If authenticated, it renders Outlet.Second, a RoleRoute that checks user.role against allowedRoles and redirects to /unauthorized if the role does not match.Third, the login page reads location.state?.from and navigates there after successful login with replace: true.Edge cases from production: the loading state during async auth check must show a spinner, not flash the login page. Token expiry during a session means the next API call returns 401 and the fetch wrapper should clear auth state. Deep link sharing must preserve the full path including query params. The race condition on mount where the route renders before auth state resolves requires gating on loading === false.Follow-up: How do you handle authentication in Next.js differently from a client-side SPA?In a client-side SPA, auth checks happen after JavaScript loads. In Next.js with server rendering, auth checks happen on the server before HTML is sent. You read the auth cookie in a Server Component or middleware and redirect before the protected content reaches the client. This is faster and more secure. The tradeoff is requiring HTTP-only cookies instead of localStorage, which adds server-side cookie management complexity.
What is the difference between useParams, useSearchParams, and useLocation? When do you use each?
Strong Answer:
These hooks represent three distinct parts of a URL. useParams reads path parameters for resource identifiers — the thing you are viewing. useSearchParams reads and writes query parameters for optional view configuration like filters, sort order, and pagination. useLocation gives the complete location object for analytics, breadcrumbs, or navigation state.The design principle: path params for identity (what resource), search params for view configuration (how to display it), location state for ephemeral navigation context (where you came from).A common mistake is putting filter state in component state instead of search params. If a user filters products and refreshes, their filters are lost. Search params make the URL the source of truth.Follow-up: How do you synchronize URL search params with React state without infinite loops?Treat the URL as the single source of truth. Read from useSearchParams directly — do not copy into useState. Update by calling setSearchParams in event handlers. This changes the URL, React Router re-renders, and the component reads new params. One render, one source of truth.The infinite loop trap: putting setSearchParams inside a useEffect that depends on search params creates a cycle. The effect sets params, triggering a re-render, which re-reads params (new reference), which re-triggers the effect. Always set params in event handlers, not effects.