Skip to main content

πŸ”— Navigation and Links

Routes decide what renders; navigation decides how users get there. Good navigation feels invisible β€” links that highlight the current page, buttons that redirect after a save, a back gesture that lands exactly where it should. This lesson turns React Router's navigation tools into muscle memory.

Week 5 · Day 2 (Tuesday: React Router) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Use <Link> for declarative navigation and pass state without exposing it in the URL
  • Style the current page with <NavLink>'s isActive and the end prop
  • Navigate programmatically with useNavigate, including replace and relative moves
  • Read the current location and query string with useLocation and useSearchParams
  • Build reusable navigation patterns: breadcrumbs, tabs, and post-action redirects
  • Make navigation accessible with semantic markup and aria-current

Estimated Time: 55 minutes

Practice: Build an accessible tab bar and a search box that stores its query in the URL.

In This Lesson

Navigation, the big picture

Think of navigation in a React app like a good GPS. It doesn't just move you between destinations β€” it remembers where you've been (the history stack), lets you retrace your route (back/forward), and can quietly reroute you when needed (a redirect after login). React Router gives you two families of tools to build that experience.

graph LR A[User intent] --> B{How to navigate?} B -->|User clicks| C[Link / NavLink] B -->|Code decides| D[useNavigate] C --> E[URL changes] D --> E E --> F[Router matches route] F --> G[New screen renders]

The rule of thumb never changes: if a human clicks to go somewhere, use a Link; if your code decides to go somewhere, use useNavigate. Everything below is a variation on those two.

Programmatic navigation

When navigation is a consequence of logic β€” a form submitted, a login succeeded, a timer expired β€” you can't wait for a click. useNavigate returns a function that moves you in code.

import { useNavigate } from 'react-router-dom';

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

  const goHome    = () => navigate('/');
  const goLogin   = () => navigate('/login', { replace: true }); // no "back" to here
  const goProfile = () => navigate('/profile', { state: { from: 'dashboard' } });
  const goBack    = () => navigate(-1);  // like the browser back button
  const goForward = () => navigate(1);

  return (
    <div>
      <button onClick={goHome}>Home</button>
      <button onClick={goBack}>Back</button>
    </div>
  );
}

A very common real-world use is redirecting after an async action completes:

function CreatePost() {
  const navigate = useNavigate();
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (formData) => {
    setIsSubmitting(true);
    try {
      const newPost = await createPost(formData);
      // Jump straight to the freshly created post
      navigate(`/posts/${newPost.id}`);
    } catch (error) {
      console.error('Failed to create post:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(readForm(e)); }}>
      <button disabled={isSubmitting}>
        {isSubmitting ? 'Creating…' : 'Create Post'}
      </button>
    </form>
  );
}

βœ… replace vs push

Default navigation pushes a new history entry, so Back returns to the previous page. Passing { replace: true } replaces the current entry β€” ideal after a login (you don't want Back to return to the login form) or after a redirect.

Reading location & query

Two hooks let you inspect where you are.

useLocation β€” the current address

import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';

function LocationAware() {
  const location = useLocation();

  // location.pathname β†’ "/users/123"
  // location.search   β†’ "?tab=profile"
  // location.hash     β†’ "#section1"
  // location.state    β†’ { from: "/dashboard" }  (set via Link/navigate)

  // A classic use: log a page view whenever the URL changes
  useEffect(() => {
    analytics.pageView(location.pathname + location.search);
  }, [location]);

  return <p>You are at {location.pathname}</p>;
}

useSearchParams β€” read & write the query string

Query strings are perfect for state you want to be shareable and bookmarkable β€” a search term, a filter, a page number. useSearchParams works like useState, but the "state" lives in the URL.

import { useSearchParams } from 'react-router-dom';

function ProductSearch() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Read (with sensible defaults)
  const query = searchParams.get('q') ?? '';
  const page  = Number(searchParams.get('page') ?? '1');

  // Write β€” build a fresh URLSearchParams so React re-renders
  const updateQuery = (newQuery) => {
    const params = new URLSearchParams(searchParams);
    params.set('q', newQuery);
    params.set('page', '1');   // reset to first page on a new search
    setSearchParams(params);
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => updateQuery(e.target.value)}
        placeholder="Search products…"
      />
      <p>Searching “{query}” β€” page {page}</p>
    </div>
  );
}

πŸ’‘ Why store state in the URL?

Put a filter in useState and refreshing loses it; share the link and your friend sees the default view. Put it in the query string and the URL is the state β€” refresh-proof, bookmarkable, and shareable. That's a big usability win for search and filter UIs.

Common patterns

A few navigation recipes come up in nearly every app. Here are three you can lift into your own projects.

1. Breadcrumbs from the path

import { useLocation, Link } from 'react-router-dom';

function Breadcrumbs() {
  const { pathname } = useLocation();
  const parts = pathname.split('/').filter(Boolean); // ["users","42","settings"]

  return (
    <nav aria-label="Breadcrumb">
      <ol>
        <li><Link to="/">Home</Link></li>
        {parts.map((part, i) => {
          const to = '/' + parts.slice(0, i + 1).join('/');
          const isLast = i === parts.length - 1;
          return (
            <li key={to}>
              {isLast ? <span aria-current="page">{part}</span>
                      : <Link to={to}>{part}</Link>}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

2. A tab bar with NavLink

function DashboardTabs() {
  const tabs = [
    { to: '/dashboard/overview',  label: 'Overview' },
    { to: '/dashboard/analytics', label: 'Analytics' },
    { to: '/dashboard/reports',   label: 'Reports' },
  ];

  return (
    <div className="tabs" role="tablist">
      {tabs.map(({ to, label }) => (
        <NavLink key={to} to={to} role="tab"
          className={({ isActive }) => `tab ${isActive ? 'active' : ''}`}>
          {label}
        </NavLink>
      ))}
    </div>
  );
}

3. Redirect back to where you came from

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  // A protected route can send you here with state.from set
  const from = location.state?.from ?? '/dashboard';

  const handleLogin = async () => {
    await logIn();
    navigate(from, { replace: true }); // return to the page they wanted
  };

  return <button onClick={handleLogin}>Log in</button>;
}

Accessible navigation

Navigation is one of the highest-impact places to get accessibility right β€” it's how everyone, including keyboard and screen-reader users, moves through your app. React Router helps, but a few habits seal the deal.

function AccessibleNav() {
  return (
    // A real <nav> with a label announces its purpose
    <nav aria-label="Main navigation">
      <ul>
        <li>
          {/* NavLink auto-adds aria-current="page" when active */}
          <NavLink to="/" end>Home</NavLink>
        </li>
        <li><NavLink to="/about">About</NavLink></li>
        <li><NavLink to="/contact">Contact</NavLink></li>
      </ul>
    </nav>
  );
}

βœ… NavLink gives you aria-current free

When a NavLink is active it automatically sets aria-current="page", which screen readers announce as "current page." That's a real accessibility win you'd otherwise have to wire by hand. Combine it with a genuine <nav aria-label> and semantic list markup and your navigation is usable by everyone.

  • Wrap primary navigation in a <nav> with a descriptive aria-label
  • Use a real skip-to-content link (like the one at the top of this page) so keyboard users can bypass the menu
  • Never build a "link" out of a <div onClick> β€” <Link> renders a focusable anchor for you

Practice & Quiz

πŸ‹οΈ Exercise 1: An accessible tab bar

Goal: Build a tab bar from an array of { to, label } objects where the current tab is visually active and announced to screen readers.

const tabs = [
  { to: '/settings/profile', label: 'Profile' },
  { to: '/settings/billing', label: 'Billing' },
];
// TODO: render a NavLink per tab with an active class
πŸ’‘ Hint

Map over tabs and render a <NavLink> for each. The className callback receives { isActive }. NavLink already sets aria-current="page" when active, so screen-reader support comes for free.

βœ… Solution
import { NavLink } from 'react-router-dom';

function SettingsTabs({ tabs }) {
  return (
    <nav aria-label="Settings" className="tabs">
      {tabs.map(({ to, label }) => (
        <NavLink key={to} to={to}
          className={({ isActive }) => `tab ${isActive ? 'active' : ''}`}>
          {label}
        </NavLink>
      ))}
    </nav>
  );
}

πŸ‹οΈ Exercise 2: Search state in the URL

Goal: Make a search input whose value is stored in the query string as ?q=…, so refreshing or sharing the URL preserves the search.

βœ… Solution
import { useSearchParams } from 'react-router-dom';

function Search() {
  const [params, setParams] = useSearchParams();
  const q = params.get('q') ?? '';

  return (
    <input
      value={q}
      onChange={(e) => {
        const next = new URLSearchParams(params);
        e.target.value ? next.set('q', e.target.value) : next.delete('q');
        setParams(next);
      }}
      placeholder="Search…"
    />
  );
}

🎯 Quick Quiz

Question 1: You need to redirect the user after a successful form submit. Which tool fits?

Question 2: Why add the end prop to a <NavLink to="/">?

Question 3: Where should a shareable, bookmarkable search term live?

Best Practices & Pitfalls

βœ… Do

  • Use <Link>/<NavLink> for user clicks; reserve useNavigate for logic-driven redirects
  • Add end to any NavLink whose path is a prefix of others (especially "/")
  • Store shareable UI state (search, filters, page) in the query string with useSearchParams
  • Use { replace: true } after logins and redirects so Back doesn't return to a dead end
  • Keep navigation in semantic <nav> + list markup and let NavLink supply aria-current

❌ Don't

  • Build clickable navigation from <div onClick> β€” it's inaccessible and un-focusable
  • Put sensitive data in the URL or in state expecting it to survive a refresh (state won't)
  • Mutate searchParams in place β€” build a new URLSearchParams so React re-renders
  • Reach for useNavigate where a plain <Link> would do β€” you'd lose right-click and new-tab support

⚠️ Hooks only run inside the router

useNavigate, useLocation, and friends must be called from components rendered inside your router (below <RouterProvider> or <BrowserRouter>). Call one outside and you'll get "useNavigate() may be used only in the context of a <Router>."

Summary

πŸŽ‰ Key Takeaways

  • Clicks β†’ <Link>/<NavLink>; code β†’ useNavigate is the rule that guides every decision
  • <NavLink>'s isActive (plus end) styles the current page and sets aria-current for free
  • useNavigate supports replace and relative moves like navigate(-1)
  • useLocation reads the current address; useSearchParams makes the URL the source of truth for search & filters
  • Breadcrumbs, tabs, and post-action redirects are small, reusable compositions of these tools
  • Semantic <nav> markup + real anchors from <Link> keep navigation accessible

πŸ“š Additional Resources

πŸš€ What's Next?

You can now move users anywhere and highlight where they are. Next we compose routes inside routes: the Nested Routes lesson builds multi-level layouts where a parent screen keeps its shell while child routes swap through a shared <Outlet />.

πŸŽ‰ Navigation mastered!

Links that highlight, redirects that land right, and URLs that remember β€” your app now feels like a real product.