React NotesRohit’s interview study guide
Chapter 14

Routing

How a single-page app changes pages without reloading, React Router 8 from <BrowserRouter> to data routers with loaders and actions, protected and lazy routes, and TanStack Router's type-safe params and route loaders.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Client-side routing

Added

In a traditional website every link is a request: the browser asks the server for /about, throws the current page away and renders the new HTML. A single-page app (SPA) loads one HTML page and one JavaScript bundle, and from then on JavaScript swaps the content when the URL changes. Nothing is fetched except data and, sometimes, lazily loaded code.

Client-side routing is the glue that makes that feel like a normal website. It has three jobs: change the URL without a request (history.pushState), notice when the URL changes (your own navigations, plus the browser's Back and Forward buttons, which fire popstate), and pick which components to render for the current URL (matching). React Router, TanStack Router and Next.js all do these three things; they differ in how much they add on top.

A good mental model: the URL is a piece of state that lives in the address bar instead of in useState. Like any state, you read it while rendering, you change it in event handlers, and when it changes the UI re-renders. The extra rules are that it's shareable (copy the link), it survives a refresh, and the browser's buttons can change it too.

MiniRouter.jsx
import { useSyncExternalStore } from "react";

// 1. The "store" is the browser's own URL. Subscribing means listening for changes to it.
function subscribe(onChange) {
  window.addEventListener("popstate", onChange); // Back / Forward buttons
  window.addEventListener("pushstate", onChange); // our own navigations (see navigate)
  return () => {
    window.removeEventListener("popstate", onChange);
    window.removeEventListener("pushstate", onChange);
  };
}

const getPath = () => window.location.pathname;

export function usePath() {
  return useSyncExternalStore(subscribe, getPath);
}

// 2. Navigating = change the URL without a request, then tell subscribers.
export function navigate(to) {
  window.history.pushState({}, "", to);
  window.dispatchEvent(new Event("pushstate")); // pushState itself fires no event
}

// 3. A link is a real <a href> (so it can be opened in a new tab and crawled)
//    whose plain left-click is intercepted.
export function MiniLink({ to, children }) {
  function handleClick(event) {
    if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return; // let the browser open a new tab
    event.preventDefault(); // stop the full page load
    navigate(to);
  }
  return (
    <a href={to} onClick={handleClick}>
      {children}
    </a>
  );
}

// 4. Matching: pick a component for the current path.
const routes = {
  "/": () => <h1>Home</h1>,
  "/about": () => <h1>About</h1>,
};

export function MiniApp() {
  const path = usePath();
  const Page = routes[path] ?? (() => <h1>Not found</h1>);
  return (
    <>
      <nav>
        <MiniLink to="/">Home</MiniLink> <MiniLink to="/about">About</MiniLink>
      </nav>
      <Page />
    </>
  );
}
What’s happening
  1. On the first render usePath() reads window.location.pathname, which is "/", so MiniApp looks up routes["/"] and renders <h1>Home</h1>. useSyncExternalStore (see useSyncExternalStore) also calls subscribe, so React is now listening for popstate and our custom pushstate event.
  2. Clicking "About" runs handleClick. It's a plain left-click with no modifier keys, so event.preventDefault() cancels the browser's normal behaviour (a full page load of /about). The test checks this: the click event arrives with defaultPrevented === true.
  3. navigate("/about") calls history.pushState({}, "", "/about"). The address bar changes and a history entry is added, but no request is made and no event fires — pushState is silent by design. That's why the next line dispatches a pushstate event of our own.
  4. The event reaches the listener useSyncExternalStore registered. React calls getPath() again, sees "/about" instead of "/", and re-renders MiniApp, which now renders <h1>About</h1>. The test confirms location.pathname is "/about" and the heading changed.
  5. The Back button (history.back() in the test) changes the URL back to "/" and the browser fires popstate — the second thing we subscribed to — so the page returns to Home. history.length is 2: the start page plus the /about entry we pushed.
  6. Ctrl/Cmd-click and middle-click are left alone, so the browser opens the real href in a new tab. That's why links must be real <a href> elements, not <div onClick>: new tabs, "copy link", screen readers and crawlers all rely on the href.

A real router adds what this toy lacks: dynamic segments (/posts/:id), nested layouts, ranking when several patterns match, relative links, search params, scroll restoration, data loading and code splitting. But underneath, React Router's <BrowserRouter> does exactly this: it listens to popstate, calls pushState on navigation and re-renders with the new location.

#React Router basics

From my notes: React Router gives you declarative routing — routes are written as JSX, so the structure of the app is visible in one place. It plays well with React.lazy for code splitting, and gives you useNavigate() for programmatic navigation. The three pieces my notes call out are <BrowserRouter> (the router itself, which connects to the browser's URL and history), <Routes> (picks the best match) and <Route> (maps a path to an element).

Correcting my notes: every import in them is from react-router-dom. That package is gone. React Router v7 merged everything into react-router and kept react-router-dom only as a re-export so v6 apps kept working, and v8 removed it. In React Router 8, import everything from react-router; only the DOM-specific RouterProvider and HydratedRouter used with data and framework routers come from react-router/dom. The JSX itself — <Routes>, <Route path element> — is unchanged since v6.

Here is the "full fledged example" from my notes, updated to v8 (imports changed, parseInt(id, 10) replaced with Number(id), and the routes pulled into an AppRoutes component so a test can render them inside a MemoryRouter):

BlogApp.jsx
import { BrowserRouter, Routes, Route, Link, useNavigate, useParams } from "react-router";

const posts = [
  { id: 1, title: "React Router Introduction", content: "This is a basic introduction to React Router." },
  { id: 2, title: "Advanced React Router Concepts", content: "Learn advanced routing techniques." },
  { id: 3, title: "React Hooks Overview", content: "An overview of React Hooks in modern React." },
];

export default function App() {
  return (
    <BrowserRouter>
      <AppRoutes />
    </BrowserRouter>
  );
}

export function AppRoutes() {
  return (
    <div>
      <Navigation />
      <hr />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/posts/:id" element={<PostDetails />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

function Navigation() {
  return (
    <nav>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
      </ul>
    </nav>
  );
}

function Home() {
  return (
    <div>
      <h2>Home Page</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

function About() {
  return <h2>About Page</h2>;
}

function PostDetails() {
  const { id } = useParams(); // always a string: "2", not 2
  const navigate = useNavigate();
  const post = posts.find((p) => p.id === Number(id));

  if (!post) return <NotFound />;

  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      <button onClick={() => navigate("/")}>Go back to Home</button>
    </div>
  );
}

function NotFound() {
  return (
    <div>
      <h2>404 - Page Not Found</h2>
      <Link to="/">Go to Home</Link>
    </div>
  );
}
What’s happening
  1. <BrowserRouter> sits at the top and owns the location. It reads window.location, subscribes to history changes and puts the current location in React context, so every <Routes>, <Link> and hook below it can read it. Nothing routing-related works outside a router: a test calling useNavigate() without one gets "useNavigate() may be used only in the context of a <Router> component."
  2. <Navigation> and <hr> are outside <Routes>, so they render on every page. Only the part inside <Routes> changes with the URL. This is how you keep a header and footer while the page content swaps.
  3. At /, <Routes> compares the path with each <Route path> and renders <Home />. Each <Link to> renders a real anchor — the test prints <a href="/posts/3" data-discover="true">React Hooks Overview</a> (the data-discover attribute is used by framework mode's lazy route discovery; it's harmless here).
  4. Clicking "Advanced React Router Concepts" navigates to /posts/2 without a reload. /posts/:id matches, with :id captured as the string "2". useParams() returns { id: "2" }, so the lookup has to convert it — p.id === id would compare 2 === "2" and never find anything. The heading becomes "Advanced React Router Concepts".
  5. "Go back to Home" calls navigate("/"), a programmatic version of clicking a link. Use it after something happens in code (a save finished, a timer fired); use <Link> when the user is clicking something that is semantically a link.
  6. /posts/99 matches /posts/:id but finds no post, so PostDetails renders <NotFound /> itself. /does/not/exist matches nothing specific and falls through to path="*", the splat route. Both cases are tested and both show "404 - Page Not Found".

Two rules make <Routes> predictable. First, it renders only the best match, so you never need v5's <Switch>. Second, the best match is chosen by specificity, not order: static segments beat dynamic ones, which beat splats. A test puts <Route path="/posts/:id"> before <Route path="/posts/new"> and visits /posts/new — the static route still wins.

AdvancedOlder versions: v5, v6 and v7 side by side

Interviewers often ask what changed, because many codebases are still on v5 or v6. The same routes in React Router v5 (2019–2021, not runnable here):

App.v5.jsx
import { BrowserRouter, Switch, Route, Redirect, useHistory } from "react-router-dom"; // v5

function App() {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/posts/:id" render={(props) => <PostDetails {...props} />} />
        <Redirect from="/old-about" to="/about" />
        <Route component={NotFound} />
      </Switch>
    </BrowserRouter>
  );
}

function SaveButton() {
  const history = useHistory();
  return <button onClick={() => history.push("/")}>Save</button>;
}
What’s happening
  1. <Switch> rendered the first route that matched, in source order. Order mattered, which is why the next line needed exact.
  2. exact on / was essential: without it / is a prefix of every URL and would match everything, so Home would render for /posts/2 too. v6 made every path match exactly by default and dropped the prop.
  3. Routes took component={Home} or render={...}, and the matched component received history, location and match as props. v6 replaced these with element={<Home />}, so you pass props normally and read router data with hooks.
  4. <Redirect> became <Navigate replace> in v6, useHistory() became useNavigate() (history.push(x) → navigate(x), history.replace(x) → navigate(x, { replace: true })), and the withRouter HOC was removed in favour of hooks.
  5. v6.4 (2022) added data routers — createBrowserRouter with loaders and actions, taken from Remix. v7 (2024) merged Remix in as "framework mode" and collapsed the packages into react-router. v8 removed react-router-dom and requires React 19.2.7+ and Node 22.22+ (from the v8 changelog).

React Router 8 has three modes, and you pick one by which top-level API you use:

ModeEntry pointYou get
Declarative<BrowserRouter> + <Routes>URL matching, links, params, useNavigate — the classic SPA router (this topic and the next few)
DatacreateBrowserRouter([...]) + <RouterProvider>Everything above plus loaders, actions, pending states, error boundaries per route (see Data routers: loaders and actions)
Frameworkthe @react-router/dev Vite plugin + routes.tsData mode plus file-based route modules, type generation, SSR/SSG/SPA builds, code splitting — what Remix used to be

#Route params and useLocation

A URL carries data in three places, and React Router gives you a hook for each. Path params are named segments in the route pattern — /products/:productId turns /products/2 into { productId: "2" }, read with useParams(). Search params are the query string — ?sort=price&page=2 — read and written with useSearchParams(), which works like useState but stores the value in the URL. And useLocation() gives you the whole location object: pathname, search, hash, state and a unique key.

The mental model: path params identify the resource (which product), search params describe a view of it (sorted how, which page, which filter). Both are just strings in the address bar, which is why they survive a refresh and can be shared — unlike useState.

Products.jsx
import { useState } from "react";
import { Link, Routes, Route, useLocation, useParams, useSearchParams } from "react-router";

const products = [
  { id: 1, name: "Keyboard", price: 80 },
  { id: 2, name: "Mouse", price: 25 },
  { id: 3, name: "Monitor", price: 300 },
];

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const sort = searchParams.get("sort") ?? "name"; // URL is the state

  const sorted = [...products].sort((a, b) =>
    sort === "price" ? a.price - b.price : a.name.localeCompare(b.name),
  );

  return (
    <>
      <button onClick={() => setSearchParams({ sort: "price" })}>Sort by price</button>
      <ul>
        {sorted.map((p) => (
          <li key={p.id}>
            <Link to={`/products/${p.id}`}>{p.name}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

function ProductDetails() {
  const { productId } = useParams();
  const product = products.find((p) => p.id === Number(productId));
  const [qty, setQty] = useState(1); // local state: survives a param change!
  return (
    <>
      <h1>{product?.name ?? "Unknown product"}</h1>
      <p>
        Typeof param: {typeof productId} · Qty: {qty}
      </p>
      <button onClick={() => setQty((q) => q + 1)}>Add one</button>
      <Link to={`/products/${Number(productId) + 1}`}>Next product</Link>
    </>
  );
}

export function WhereAmI() {
  const location = useLocation();
  return <pre>{JSON.stringify(location)}</pre>;
}

export function ProductRoutes() {
  return (
    <>
      <Routes>
        <Route path="/products" element={<ProductList />} />
        <Route path="/products/:productId" element={<ProductDetails />} />
      </Routes>
      <WhereAmI />
    </>
  );
}
What’s happening
  1. At /products, searchParams.get("sort") is null, so sort falls back to "name" and the list renders Keyboard, Monitor, Mouse (alphabetical).
  2. Clicking "Sort by price" calls setSearchParams({ sort: "price" }). That's a navigation: the URL becomes /products?sort=price, a history entry is pushed, and the component re-renders with sort === "price": Mouse (25), Keyboard (80), Monitor (300). Back would undo the sort, and the sorted view can be bookmarked — neither would be true with useState.
  3. WhereAmI shows what useLocation() returns after the sort: { pathname: '/products', search: '?sort=price', hash: '', state: null, key: 'z74jrd5y' }. key is unique per history entry ('default' for the initial one); it's useful as a dependency when you want an effect to run on every navigation, even to the same URL.
  4. At /products/1#reviews, useParams() returns { productId: "1" } and typeof productId renders string. Params are always strings (or undefined for optional segments), so convert them before comparing or doing arithmetic; Number(productId) + 1 builds the "Next product" link to /products/2. The hash #reviews lands in location.hash, not in params.
  5. Clicking "Add one" twice makes qty 1 → 2 → 3. Then "Next product" goes to /products/2 and the heading changes to Mouse — but Qty is still 3. Same route, same ProductDetails element in the same position, so React keeps the component instance and its state; only the param changed.
  6. This is the classic param-change bug: a form or counter "leaks" from one item to the next. The next example fixes it.
ResetOnChange.jsx
function ResetOnChange() {
  const { productId } = useParams();
  return <ProductDetails key={productId} />; // new key = new component = fresh state
}

<Route path="/products/:productId" element={<ResetOnChange />} />;
What’s happening
  1. ResetOnChange reads the param and renders ProductDetails with key={productId}.
  2. On /products/1 the key is "1". Adding one makes Qty 2.
  3. Going to /products/2 changes the key to "2". React treats a different key as a different component: it unmounts the old ProductDetails (throwing its state away) and mounts a fresh one, so the test sees "Mouse" with Qty back at 1.
  4. Use this when the whole page is "about" the param. If only one piece of state should reset, keep the key and reset that piece in an event handler instead; effects that fetch by id should simply list the id as a dependency.

Patterns you can use in path: :id (required param), :lang? (optional segment, /:lang?/about matches /about and /fr/about), files/* (splat; the rest of the path is in params["*"]). Typical uses of useLocation(): logging page views on location.pathname, reading location.state (next topic), building a "return to" URL for login.

Which params object?

Given <Route path="/users/:userId/posts/:postId?" element={<Post />} />, what does useParams() return in Post at /users/7/posts, and what does typeof params.userId give at /users/7/posts/42?

Show answer

At /users/7/posts it returns { userId: "7" } with postId missing, and at /users/7/posts/42 it's { userId: "7", postId: "42" }, so typeof params.userId is "string".

What’s happening
  1. The pattern has a required segment :userId and an optional one :postId?. Optional segments let one route match both /users/7/posts and /users/7/posts/42.
  2. At /users/7/posts the optional segment is absent, so there's no postId value: params.postId is undefined. Code must handle that, e.g. render a list instead of one post.
  3. At /users/7/posts/42 both segments are present and both values are the raw text from the URL: "7" and "42". React Router never converts them, because a segment like 007 or abc is equally valid in a URL.
  4. So typeof params.userId is "string", and params.userId === 7 is false. Convert at the edge (Number(params.userId)), or validate with a schema if bad input is possible.

#Passing data through route state

Sometimes you want to hand data to the next page without putting it in the URL: a flash message ("Saved!"), the page to return to after login, or the item the user just clicked so the detail page can show it instantly. React Router lets you attach a state value to a navigation. It's stored in the browser's own history entry (history.state), not in the address bar, and the next page reads it from useLocation().state.

The mental model is a note clipped to the history entry. It travels with that one entry — Back and Forward bring it back — but anyone who arrives at the same URL another way (typing it, a bookmark, a new tab, a shared link) gets the page without the note. So route state is for optional extras, never for data the page can't work without.

From my notes, the two ways to set it — <Link state> and navigate(path, { state }) — with the missing useLocation import added and || changed to ??:

RouteState.jsx
import { Link, Routes, Route, useLocation, useNavigate } from "react-router";

function HomePage() {
  const navigate = useNavigate();

  const goToDetails = () => {
    navigate("/details", { state: { message: "Hello from Home!" } });
  };

  return (
    <div>
      <h1>Home Page</h1>
      <Link to="/details" state={{ message: "Hello via Link!" }}>
        Go to Details
      </Link>
      <button onClick={goToDetails}>Go to Details (via useNavigate)</button>
    </div>
  );
}

function DetailsPage() {
  const location = useLocation();
  const message = location.state?.message ?? "No message";

  return (
    <div>
      <h1>Details Page</h1>
      <p>{message}</p>
    </div>
  );
}

export function RouteStateDemo() {
  return (
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="/details" element={<DetailsPage />} />
    </Routes>
  );
}
What’s happening
  1. Clicking the link navigates to /details with state = { message: "Hello via Link!" }. The URL is just /details — the test prints http://localhost:3000/details — so the message isn't visible or shareable.
  2. With BrowserRouter, the state is saved inside window.history.state. The test prints exactly what React Router stores there: {"usr":{"message":"Hello via Link!"},"key":"8udwz6nx","idx":1}. Your value is under usr; key and idx are the router's own bookkeeping for that entry.
  3. DetailsPage reads location.state?.message. The optional chaining is required: location.state is null whenever the page is reached without state.
  4. The button does the same thing in code with navigate("/details", { state: { message: "Hello from Home!" } }), and the page shows that message instead.
  5. Opening /details directly (the test uses initialEntries={["/details"]}) gives location.state === null, so the page shows "No message". This is the case you must always handle.

Where route state is the right tool:

  • Return-to-after-login: the guard passes state={{ from: location }} and the login page navigates back to from (see Protected routes).
  • Flash messages: "Post published" after a redirect; if it's lost on refresh, nothing breaks.
  • Instant previews: pass the clicked item so the detail page can render it immediately while it fetches the full record by the id in the URL.
  • Modals over the previous page: pass state={{ background: location }} and render the old page underneath a modal route.

Where it isn't: anything the page needs to work. If /checkout can't render without the cart, the cart belongs in a store, the server or the URL.

#Nested routes and Outlet

Added

Most apps have layouts inside layouts: a site shell (header, footer) around a dashboard shell (sidebar, tabs) around the actual page. Nested routes express that directly: a parent <Route> renders the shared part, and an <Outlet /> inside it marks where the matched child goes. The URL is built up from the nested paths: dashboard + settings = /dashboard/settings.

The mental model is picture frames: each matched route is a frame, and <Outlet /> is the hole in the frame where the next one sits. Navigating between siblings swaps only the innermost picture; the frames around it stay mounted, keep their state and don't re-run their effects.

Nested.jsx
import { Link, NavLink, Outlet, Routes, Route, useOutletContext } from "react-router";

function DashboardLayout() {
  const user = { name: "Rohit" };
  return (
    <section>
      <h1>Dashboard</h1>
      <nav>
        <NavLink to="." end>Overview</NavLink> {/* relative: /dashboard */}
        <NavLink to="settings">Settings</NavLink> {/* relative: /dashboard/settings */}
      </nav>
      <main>
        <Outlet context={user} /> {/* the matched child renders here */}
      </main>
    </section>
  );
}

function Overview() {
  return <h2>Overview</h2>;
}

function Settings() {
  const user = useOutletContext();
  return <h2>Settings for {user.name}</h2>;
}

function SiteLayout() {
  return (
    <>
      <header>
        <Link to="/">Site</Link>
      </header>
      <Outlet />
      <footer>© 2026</footer>
    </>
  );
}

export function NestedRoutes() {
  return (
    <Routes>
      <Route element={<SiteLayout />}> {/* layout route: no path, adds no URL segment */}
        <Route index element={<h2>Home</h2>} />
        <Route path="dashboard" element={<DashboardLayout />}>
          <Route index element={<Overview />} /> {/* /dashboard */}
          <Route path="settings" element={<Settings />} /> {/* /dashboard/settings */}
        </Route>
      </Route>
    </Routes>
  );
}
What’s happening
  1. At /dashboard, React Router finds the chain of matching routes from the outside in: the pathless SiteLayout route, then dashboard, then its index route (an index route is the child that renders when the URL stops exactly at the parent). It renders them nested: SiteLayout → its <Outlet /> → DashboardLayout → its <Outlet /> → Overview.
  2. The test prints the resulting HTML: <header>…</header><section><h1>Dashboard</h1><nav>…</nav><main><h2>Overview</h2></main></section><footer>© 2026</footer>. Each layout wrapped the next exactly where its <Outlet /> was.
  3. The first <Route element={<SiteLayout />}> has no path. That makes it a layout route: it wraps its children but adds nothing to the URL. It's how you give some pages a shell and others (login, print views) none.
  4. Inside DashboardLayout, to="settings" and to="." are relative to the route they're rendered in, so they resolve to /dashboard/settings and /dashboard — the test checks the href. Rename the parent path and the links still work. end on Overview keeps it from being active on /dashboard/settings.
  5. Clicking Settings swaps only the innermost component. The <h1>Dashboard</h1> stays (the test checks it), and Settings reads { name: "Rohit" } with useOutletContext() — the value the parent passed as <Outlet context={user} />. That's a typed-by-convention way to share layout data with child routes without a separate context provider.
  6. At /, only SiteLayout and its index route match, so there's no <h1> at all. This demonstrates that nesting in the route config mirrors nesting in the UI.

#Protected routes

Added

A protected route is a page only some users may see — an account page for logged-in users, an admin page for admins. In a client-rendered app the pattern is a guard: before rendering the page, check who the user is; if they aren't allowed, redirect them (usually to login, remembering where they were going).

The mental model is a bouncer at a door inside the club. With nested routes, you put one bouncer on a pathless layout route, and every room behind it is covered. But remember what a bouncer in the browser really is: code the user downloaded and can change. Client-side guards are user experience, not security — the API must check permissions on every request.

Protected.jsx
import { createContext, useContext, useState } from "react";
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from "react-router";

const AuthContext = createContext(null);

export function AuthProvider({ children, initialUser = null }) {
  const [user, setUser] = useState(initialUser);
  const login = (name) => setUser({ name });
  const logout = () => setUser(null);
  return <AuthContext value={{ user, login, logout }}>{children}</AuthContext>;
}

const useAuth = () => useContext(AuthContext);

// A layout route that guards every child route.
function RequireAuth() {
  const { user } = useAuth();
  const location = useLocation();
  if (!user) {
    // replace: the guarded URL doesn't stay in history, so Back doesn't bounce here again
    return <Navigate to="/login" replace state={{ from: location }} />;
  }
  return <Outlet />;
}

function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname ?? "/";

  function handleLogin() {
    login("Rohit");
    navigate(from, { replace: true }); // back to where they were going
  }

  return (
    <>
      <h1>Log in</h1>
      <p>You'll go back to {from}</p>
      <button onClick={handleLogin}>Log in as Rohit</button>
    </>
  );
}

function Account() {
  const { user } = useAuth();
  return <h1>Account of {user.name}</h1>;
}

export function ProtectedApp() {
  return (
    <Routes>
      <Route path="/" element={<h1>Public home</h1>} />
      <Route path="/login" element={<LoginPage />} />
      <Route element={<RequireAuth />}>
        <Route path="/account" element={<Account />} />
        <Route path="/orders" element={<h1>Orders</h1>} />
      </Route>
    </Routes>
  );
}
What’s happening
  1. A guest opens /account. The matched chain is RequireAuth → Account. RequireAuth renders first, finds user === null, and returns <Navigate to="/login" replace state={{ from: location }} /> instead of the <Outlet />, so Account never renders (and never crashes on user.name).
  2. <Navigate> performs the redirect after rendering. replace swaps the /account history entry for /login, so the guest's Back button goes to wherever they were before, not to /account (which would redirect them straight back). state.from carries the original location along.
  3. The login page reads location.state.from.pathname — "/account" — and the test sees "You'll go back to /account". The ?? "/" covers people who opened /login directly.
  4. Clicking "Log in as Rohit" calls login, which sets user in the context, then navigate("/account", { replace: true }). This time RequireAuth finds a user and renders <Outlet />, so the test sees "Account of Rohit". replace again keeps /login out of history.
  5. With initialUser={{ name: "Rohit" }}, /orders renders straight through: the same guard covers every child of the pathless route, so adding a protected page is one more <Route> inside it.
  6. Role checks work the same way: RequireRole({ role }) that renders <Navigate to="/403" /> or <Outlet />.
AdvancedData mode: guard in middleware, before anything renders

The component guard has one weakness: the protected component's code is already loaded and the check happens during rendering, so with data loading in effects you can briefly start fetching before the redirect. In data mode the check runs before anything renders, as route middleware (always enabled in React Router 8) or in a loader:

protected-data-router.jsx
import { createMemoryRouter, redirect, useLoaderData } from "react-router";

let session = null; // set by your login code, e.g. { name: "Rohit" }

async function authMiddleware({ request }, next) {
  if (!session) {
    const from = new URL(request.url).pathname;
    throw redirect(`/login?from=${encodeURIComponent(from)}`);
  }
  await next(); // runs the loaders below this route
}

const router = createMemoryRouter(
  [
    { path: "/login", Component: () => <h1>Login page</h1> },
    {
      middleware: [authMiddleware], // pathless parent: guards every child
      children: [
        {
          path: "/account",
          loader: () => ({ name: session.name }),
          Component: function AccountRoute() {
            const data = useLoaderData();
            return <h1>Account of {data.name}</h1>;
          },
        },
      ],
    },
  ],
  { initialEntries: ["/account"] },
);
What’s happening
  1. Navigating to /account makes the router run the matched routes' middleware from the outside in, before any loader or component. The test records the calls.
  2. As a guest, authMiddleware throws redirect("/login?from=%2Faccount"). A thrown Response from redirect() stops the navigation and starts a new one. The recorded calls are just ['middleware /account'] — the account loader never ran — and the final URL is /login?from=%2Faccount.
  3. Logged in, the calls are ['middleware /account', 'account loader']: the middleware's await next() let the loaders run, then the page rendered "Account of Rohit".
  4. Because this happens in the navigation, not in rendering, there's no flash of protected UI and no wasted request. In framework mode the same pattern exported as a route module's middleware runs on the server (clientMiddleware is the browser version), and there it is a real security check.
  5. The test also printed "No HydrateFallback element provided to render during initial hydration": a data router renders nothing until the first loaders finish, and wants a HydrateFallback on the root route to show meanwhile (see the next two topics).
What does Back do?

A guest is on / and clicks a link to /account. The guard renders <Navigate to="/login" state={{ from: location }} /> — without replace. On the login page they change their mind and press the browser's Back button. Where do they end up? And with replace?

Show answer

Without replace they end up back on the login page; with replace they go back to /.

What’s happening
  1. Without replace, the history stack becomes / → /account → /login: clicking the link pushed /account, then <Navigate> pushed /login on top.
  2. Back moves to the /account entry. The guard renders again, the user is still a guest, and <Navigate> immediately pushes /login again. The user is stuck: every Back press bounces them to login. A test that starts on /account with / behind it shows "Login" after Back.
  3. With replace, <Navigate> replaces the /account entry with /login, so the stack is / → /login. Back goes to / — the same test shows "Home".
  4. The same reasoning applies after a successful login: navigate(from, { replace: true }) swaps /login for the destination, so Back from the account page doesn't show a login form to someone who's already logged in.

#Lazy-loaded routes

From my notes: React Router "works well with lazy loading (React.lazy()) to load routes dynamically and improve performance through code splitting." Routes are the natural split points: a user on the home page doesn't need the code for the admin reports. Each lazily loaded route becomes its own chunk, fetched the first time someone navigates there.

The mental model: the main bundle is the lobby, and each lazy route is a room whose furniture is delivered on first visit. Declarative mode uses ordinary React.lazy + <Suspense> (see Code splitting with React.lazy and Suspense); data mode has its own lazy route property, which can load a route's code and data loader together.

LazyRoutes.jsx
import { lazy, Suspense } from "react";
import { Link, Routes, Route } from "react-router";

// Declarative mode: React.lazy + Suspense, exactly like any lazy component.
const Reports = lazy(() => import("./pages/Reports.jsx"));

export function LazyApp() {
  return (
    <>
      <Link to="/reports">Reports</Link>
      <Suspense fallback={<p>Loading page…</p>}>
        <Routes>
          <Route path="/" element={<h1>Home</h1>} />
          <Route path="/reports" element={<Reports />} />
        </Routes>
      </Suspense>
    </>
  );
}
What’s happening
  1. lazy(() => import("./pages/Reports.jsx")) doesn't load anything yet. The bundler sees the dynamic import() and puts Reports.jsx (and anything only it imports) in a separate chunk. Reports.jsx must export default the component.
  2. At /, only Home renders; the Reports chunk is never requested.
  3. Clicking Reports changes the location, <Routes> renders <Reports />, and the lazy component suspends while its chunk downloads. The nearest <Suspense> boundary is the one around <Routes>.
  4. You'd expect "Loading page…" now — but the test shows the old page (Home) stays on screen until Reports is ready, and the fallback never appears. React Router wraps its location updates in startTransition, and React keeps showing the current UI during a transition instead of replacing already-visible content with a fallback (see useTransition).
  5. The fallback does appear when there's nothing to keep: opening /reports directly, or routers created with useTransitions={false} — with that prop a test sees "Loading page…" during the same click.
  6. Once the chunk arrives, Reports renders and the module is cached, so later visits are instant.

In data mode, routes declare their lazy parts in the route object, and the router loads them as part of the navigation:

lazy-data-router.jsx
import { Link } from "react-router";

export const lazyRoutes = [
  {
    path: "/",
    HydrateFallback: () => <p>Starting…</p>,
    children: [
      { index: true, Component: () => <Link to="/invoices">Invoices</Link> },
      {
        path: "invoices",
        lazy: async () => {
          const { Component, loader } = await import("./pages/invoices.jsx");
          return { Component, loader };
        },
      },
    ],
  },
];

// pages/invoices.jsx
//   export async function loader() { return { invoices: ["INV-1", "INV-2"] }; }
//   export function Component() { const { invoices } = useLoaderData(); … }
What’s happening
  1. The invoices route has no Component or loader up front, only a lazy function. The router knows the path exists (so it can match it) without having its code.
  2. Clicking Invoices starts a navigation; router.state.navigation.state goes 'loading' (the test records it). The router calls lazy(), gets back { Component, loader }, merges them into the route, runs the loader, and only then renders.
  3. The test then finds the "Invoices" heading with the items INV-1 and INV-2, and the recorded states are ['loading', 'idle'].
  4. Why this beats React.lazy for data routes: with React.lazy the component must download and render before its effect can start fetching data — a waterfall (code, then data). The lazy property puts code and loader in the router's hands, and the object form (lazy: { loader: () => import(...), Component: () => import(...) }) loads the two in parallel.
  5. During the navigation the previous page stays on screen; show progress with useNavigation() (next topic) rather than a Suspense fallback.

#Data routers: loaders and actions

Added

Declarative routing answers "which components for this URL?" and leaves data to the components: each one fetches in an effect after it mounts. That causes request waterfalls (parent renders, fetches, renders child, child fetches…) and makes every component handle its own loading and error states. Data routers (React Router's data mode, added in v6.4 from Remix) move data into the route definitions: a loader fetches data before the route renders, an action handles a form submission, and the router coordinates them.

The mental model is the old server-rendered web, in the browser. A GET navigation calls the matched routes' loaders (in parallel) and renders when they're done; a <Form method="post"> calls the route's action, then re-runs the loaders so the page shows fresh data — like a form post followed by a page reload, without the reload. Components just read useLoaderData(); pending and error states live in the router.

DataRouter.jsx
import {
  Form,
  Link,
  Outlet,
  data,
  isRouteErrorResponse,
  useActionData,
  useLoaderData,
  useNavigation,
  useRouteError,
} from "react-router";

export const db = { todos: [{ id: 1, title: "Read the router docs" }] };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

async function todosLoader() {
  await wait(10);
  return { todos: [...db.todos] };
}

async function todoLoader({ params }) {
  const todo = db.todos.find((t) => t.id === Number(params.todoId));
  if (!todo) throw data("Todo not found", { status: 404 });
  return { todo };
}

async function addTodoAction({ request }) {
  const formData = await request.formData();
  const title = String(formData.get("title") ?? "").trim();
  if (!title) return { error: "Title is required" }; // returned, not thrown: shown in the form
  await wait(10);
  db.todos.push({ id: db.todos.length + 1, title });
  return { ok: true };
}

function Root() {
  const navigation = useNavigation();
  return (
    <>
      <p role="status">{navigation.state}</p>
      <Outlet />
    </>
  );
}

function Todos() {
  const { todos } = useLoaderData();
  const result = useActionData();
  const navigation = useNavigation();
  const busy = navigation.state === "submitting";

  return (
    <>
      <h1>Todos</h1>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <Link to={`/todos/${t.id}`}>{t.title}</Link>
          </li>
        ))}
      </ul>
      <Form method="post">
        <input name="title" aria-label="Title" />
        <button disabled={busy}>{busy ? "Saving…" : "Add"}</button>
      </Form>
      {result?.error && <p role="alert">{result.error}</p>}
    </>
  );
}

function TodoPage() {
  const { todo } = useLoaderData();
  return <h1>{todo.title}</h1>;
}

function ErrorPage() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <h1>{error.status}: {error.data}</h1>;
  }
  return <h1>Something went wrong</h1>;
}

export const routes = [
  {
    path: "/",
    Component: Root,
    ErrorBoundary: ErrorPage,
    HydrateFallback: () => <p>Loading app…</p>,
    children: [
      { path: "todos", loader: todosLoader, action: addTodoAction, Component: Todos },
      { path: "todos/:todoId", loader: todoLoader, Component: TodoPage },
    ],
  },
];

// main.jsx
// import { createBrowserRouter } from "react-router";
// import { RouterProvider } from "react-router/dom";
// const router = createBrowserRouter(routes); // once, outside React
// createRoot(document.getElementById("root")).render(<RouterProvider router={router} />);
What’s happening
  1. Routes are objects created once, outside React, with createBrowserRouter (tests use createMemoryRouter(routes, { initialEntries: ["/todos"] })). Each route can have a Component, a loader, an action and an ErrorBoundary. The router must not be created inside a component or kept in state.
  2. On the initial load of /todos, the router runs todosLoader before rendering the page. Until it resolves, the root's HydrateFallback shows — the test's first paint is exactly "Loading app…". Then Todos renders with useLoaderData() already filled: no loading flag, no effect, no null check.
  3. Typing a title and clicking Add submits the <Form method="post">. Instead of a browser POST, the router builds a Request with the form data and calls the route's action. The test records the navigation states: ['idle', 'submitting POST', 'loading POST', 'idle'] — submitting while the action runs (the button shows "Saving…" and is disabled), loading while the router re-runs the loaders, then idle.
  4. That revalidation is the key feature: the action only saved the todo, yet the list updates to two items because todosLoader ran again. You never write "update the local list after saving" code; the loaders are the single source of truth.
  5. Submitting an empty title makes the action return { error: "Title is required" }. Returned values reach useActionData(), so the form shows the error in role="alert" and the list stays at one item. Return for expected validation errors; throw for real failures.
  6. /todos/42 makes todoLoader throw data("Todo not found", { status: 404 }). A thrown value goes to the nearest ErrorBoundary; isRouteErrorResponse(error) recognises it, and the page shows "404: Todo not found". Every route can have its own boundary, so a failing child doesn't take down the layout around it.
AdvancedMore data-mode APIs

What else data mode gives you, briefly:

  • useFetcher() — call a loader or action without navigating (a "like" button, a combobox search), with its own pending state; many can run at once.
  • useNavigation() — global pending UI: a top progress bar while navigation.state !== "idle".
  • shouldRevalidate — opt a route out of re-running its loader after unrelated actions.
  • <Await> with promises returned from loaders — return { critical, slow: slowPromise } and render the slow part in <Suspense> + <Await> so the page doesn't wait for it.
  • Middleware — code that runs around every navigation's loaders and actions (auth, logging, shared context); the future.v8_middleware flag from v7 is gone because it's always on in v8.

#TanStack Router

TanStack Router (from the authors of TanStack Query) is the main alternative to React Router for client-rendered apps. Its selling point is type safety end to end: route paths, path params, search params and loader data are all inferred by TypeScript, so <Link to="/posts/$postId" params={{ postId }} /> is checked at compile time — a typo in the path or a missing param is a type error, not a 404. It also treats search params as validated, typed state, and has built-in loader caching with stale-while-revalidate.

From my notes: TanStack Router supports nested routes with <Outlet />, dynamic routes with parameters, and — marked as the best feature — data fetching with route loaders. The mental model is the same as React Router's data mode (routes own their data; components read it), with stronger types and a small cache.

Correcting my notes: the example in my notes builds the router as createRouter({ routes: [{ path, component, children }] }) and passes <Outlet /> as a child of <RouterProvider>. That isn't TanStack Router's API (it looks like a mix of React Router's object routes and TanStack's names). The real code-based API creates each route with createRoute, links it to its parent with getParentRoute, assembles a tree with addChildren, and renders <RouterProvider router={router} /> with no children — the root route's component contains the <Outlet />. The params and loader examples in my notes were screenshots that didn't survive, so the version below is written fresh and tested against @tanstack/react-router 1.170.

TanStack.jsx
import {
  Link,
  Outlet,
  RouterProvider,
  createBrowserHistory,
  createRootRouteWithContext,
  createRoute,
  createRouter,
  notFound,
  redirect,
} from "@tanstack/react-router";

const posts = [
  { id: "1", title: "Routing in React" },
  { id: "2", title: "Loaders are the best feature" },
];

const api = {
  async getPosts() {
    return posts;
  },
  async getPost(id) {
    const post = posts.find((p) => p.id === id);
    if (!post) throw notFound(); // renders the notFoundComponent
    return post;
  },
};

const rootRoute = createRootRouteWithContext()({
  component: () => (
    <>
      <nav>
        <Link to="/">Home</Link> <Link to="/posts">Posts</Link>
      </nav>
      <Outlet /> {/* nested routes render here, just like React Router */}
    </>
  ),
  notFoundComponent: () => <h1>Not found</h1>,
});

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/",
  component: () => <h1>Home</h1>,
});

const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/posts",
  // validateSearch turns the raw query string into typed, validated values
  validateSearch: (search) => ({ page: Number(search.page ?? 1) }),
  loader: () => api.getPosts(),
  component: function Posts() {
    const posts = postsRoute.useLoaderData();
    const { page } = postsRoute.useSearch();
    return (
      <>
        <h1>Posts (page {page})</h1>
        <ul>
          {posts.map((p) => (
            <li key={p.id}>
              <Link to="/posts/$postId" params={{ postId: p.id }}>
                {p.title}
              </Link>
            </li>
          ))}
        </ul>
      </>
    );
  },
});

const postRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/posts/$postId", // $ marks a dynamic segment (React Router uses :postId)
  loader: ({ params }) => api.getPost(params.postId),
  component: function Post() {
    const post = postRoute.useLoaderData();
    const { postId } = postRoute.useParams();
    return <h1>#{postId}: {post.title}</h1>;
  },
});

const adminRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/admin",
  beforeLoad: ({ context, location }) => {
    if (!context.auth.user) {
      throw redirect({ to: "/login", search: { redirect: location.href } });
    }
  },
  component: () => <h1>Admin</h1>,
});

const loginRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: "/login",
  validateSearch: (search) => ({ redirect: String(search.redirect ?? "/") }),
  component: function Login() {
    const { redirect: back } = loginRoute.useSearch();
    return <h1>Login (then back to {back})</h1>;
  },
});

const routeTree = rootRoute.addChildren([indexRoute, postsRoute, postRoute, adminRoute, loginRoute]);

const router = createRouter({
  routeTree,
  history: createBrowserHistory(), // the default; tests pass createMemoryHistory({ initialEntries: [url] })
  context: { auth: { user: null } },
});

export function App() {
  return <RouterProvider router={router} />;
}
What’s happening
  1. Every route is created with createRoute, and getParentRoute: () => rootRoute says where it sits in the tree. rootRoute.addChildren([...]) builds the tree, and createRouter({ routeTree, context }) turns it into a router. createRootRouteWithContext() declares that every route can read a shared context — here { auth } — in its beforeLoad and loader.
  2. Visiting /posts/2: $postId is TanStack's dynamic-segment syntax (React Router writes :postId). The router runs the route's loader with params.postId === "2" before rendering; the test's API log shows a single call, getPost(2). Then Post reads postRoute.useLoaderData() and postRoute.useParams() and renders "#2: Loaders are the best feature".
  3. Visiting /posts?page=3: validateSearch receives { page: 3 } (TanStack's default parser runs each value through JSON.parse, so "3" arrives as the number 3) and returns the shape the route works with; useSearch() returns { page: 3 } and the heading says "Posts (page 3)". With TypeScript, page is typed number everywhere, and every <Link to="/posts"> would be required to supply a valid search.
  4. <Link to="/posts/$postId" params={{ postId: p.id }}> builds the URL from the pattern plus params; the test prints <a href="/posts/1">Routing in React</a>. You never build URL strings by hand, so a renamed route or param is caught by the compiler.
  5. /posts/99 makes getPost throw notFound(), which renders the root's notFoundComponent ("Not found"). /admin as a guest makes beforeLoad throw redirect({ to: "/login", search: { redirect: location.href } }) before the route loads; the test ends at /login?redirect=%2Fadmin showing "Login (then back to /admin)". With context.auth.user set, the same URL renders "Admin".
  6. Loader results are cached per route + params. With the default staleTime of 0, going Posts → a post → back to Posts shows the cached list immediately and re-runs the loader in the background (the test's log: getPosts, getPost(1), getPosts). With createRouter({ ..., defaultStaleTime: 30_000 }) the same trip makes no second request (getPosts, getPost(1)).
AdvancedRegistering the router type, and file-based routes

Two things make the types work in a TypeScript project, and neither shows in plain JS. First, you register the router type once so every Link, useNavigate and useParams in the app knows your routes:

router.tsx
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

Second, most projects don't write the tree by hand: with the @tanstack/router-plugin Vite plugin (not installed in the sandbox), each file under src/routes/ defines one route with export const Route = createFileRoute("/posts/$postId")({ loader, component }), and the plugin generates routeTree.gen.ts for you. File names map to paths (posts.$postId.tsx → /posts/$postId, __root.tsx → the root route). The route options are the same ones shown above.

TanStack Router vs React Router, as you'd say it in an interview:

React Router 8TanStack Router 1.x
Params syntax/posts/:postId/posts/$postId
Type safetyGenerated types in framework mode; plain strings in declarative/data modeInferred everywhere from the route tree (paths, params, search, loader data)
Search paramsURLSearchParams stringsValidated, typed objects (validateSearch, works with Zod)
Data loadingLoaders/actions, revalidation after actions, no cacheLoaders with a built-in SWR cache (staleTime, gcTime, preloading on hover)
Mutationsaction + <Form>Bring your own (usually TanStack Query useMutation)
Full-stackFramework mode (the former Remix)TanStack Start

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.