Skip to main content

🧭 Programmatic Navigation

A <Link> is great when the user decides to go somewhere. But some moves are decided by your code: redirect to the dashboard after a successful login, bounce a logged-out visitor to the sign-in page, jump to the confirmation screen once an order clears. For those, React Router hands you a steering wheel β€” the useNavigate hook.

Week 5 · Day 3 (Wednesday: Advanced Routing) · Lecture 3

🎯 Learning Objectives

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

  • Navigate from event handlers and effects with the useNavigate hook
  • Choose between a <Link> and a programmatic navigate() call
  • Redirect after async work β€” form submits, logins β€” and handle failures
  • Use replace to keep one-way screens out of the history stack
  • Pass and read hidden location state across a navigation
  • Move through history with navigate(-1) and protect routes with a redirect pattern

Estimated Time: 70 minutes

Practice: Build a login form that redirects to the page the user originally wanted.

In This Lesson

Link vs Navigate

Both a <Link> and useNavigate change the URL without a full page reload. The difference is who pulls the trigger. A <Link> is a piece of UI the user clicks β€” it renders a real anchor, works with middle-click and "open in new tab," and should be your default. useNavigate is for navigation that happens as a consequence of logic: an await resolving, a condition failing, a timer firing.

graph TD A[Need to change route] --> B{Who decides?} B --> |User clicks a destination| C["<Link to='...' />
(default β€” it's an anchor)"] B --> |Your code decides| D["useNavigate()"] D --> E[After a form submit] D --> F[After login / auth check] D --> G[Redirect on a condition] D --> H["Back / forward: navigate(-1)"]

πŸ“– Rule of thumb

If a user could reasonably want to right-click and open in a new tab, it's a <Link>. If the navigation is a side effect of something that already happened, it's useNavigate. Don't wrap a <Link> around a button just to call navigate in its onClick β€” pick the right tool.

The useNavigate Hook

Call useNavigate() at the top of your component to get a navigate function, then call that function to move. It accepts a path string (absolute or relative), an options object, or a number to walk history.

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

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

  return (
    <div>
      {/* Absolute path β€” matches from the site root */}
      <button onClick={() => navigate('/about')}>About</button>

      {/* Relative path β€” resolved against the current route */}
      <button onClick={() => navigate('settings')}>Settings</button>

      {/* Replace the current history entry instead of pushing */}
      <button onClick={() => navigate('/login', { replace: true })}>Login</button>

      {/* Carry hidden state along with the navigation */}
      <button onClick={() => navigate('/profile', { state: { from: 'dashboard' } })}>
        Profile
      </button>

      {/* Walk the history stack */}
      <button onClick={() => navigate(-1)}>Back</button>
      <button onClick={() => navigate(1)}>Forward</button>
    </div>
  );
}

⚠️ Don't call navigate during render

navigate() is a side effect, so it belongs in an event handler or a useEffect β€” never in the component body during render. Calling it at render time throws a warning and can loop. If you want to redirect declaratively during render, use the <Navigate> component instead:

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

function Dashboard({ user }) {
  if (!user) return <Navigate to="/login" replace />;  // βœ… safe during render
  return <RealDashboard />;
}

Redirecting After Async Work

The most common use of useNavigate: do some async work, then move the user based on the result. A registration form submits, waits for the server, and on success sends the user to a welcome screen β€” on failure, it stays put and shows the error.

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

function RegistrationForm() {
  const navigate = useNavigate();
  const [form, setForm] = useState({ username: '', email: '', password: '' });
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

    try {
      const { user } = await registerUser(form);
      // Success β†’ move on, passing a little context to the next page:
      navigate('/welcome', { state: { user, isNewUser: true } });
    } catch (err) {
      // Failure β†’ stay on the form and surface the message (don't navigate away)
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* fields bound to form state ... */}
      {error && <p className="error">{error}</p>}
      <button type="submit" disabled={submitting}>
        {submitting ? 'Registering…' : 'Register'}
      </button>
    </form>
  );
}

πŸ’‘ On failure, prefer local state to a redirect

The original instinct is often to navigate('/error') when something fails. Usually that's worse UX β€” it throws away what the user typed. Showing the error in place (as above) lets them fix a typo and retry. Reserve navigating-to-an-error-page for truly unrecoverable situations.

replace & Location State

Push vs replace

By default navigate() pushes a new entry onto the history stack, so Back returns to where you were. Passing { replace: true } swaps the current entry instead β€” the page you left is forgotten. That's exactly what you want after a login or a completed checkout, so the Back button doesn't drop the user onto a form they already submitted.

Push adds a history entry while replace swaps the current one push('/dashboard') /home /login /dashboard ← Back returns to /login replace('/dashboard') /home /dashboard ← /login was replaced, Back skips it
Push keeps /login in history; replace overwrites it so Back never returns to the login screen.

Passing location state

The second argument's state field ships hidden data along with a navigation β€” not visible in the URL, ideal for "where did this person come from" or a one-time flag. Read it on the destination with useLocation.

// Source:
navigate('/order-success', { state: { orderId: result.id }, replace: true });

// Destination:
import { useLocation } from 'react-router-dom';

function OrderSuccess() {
  const location = useLocation();
  const orderId = location.state?.orderId;   // optional-chain: state may be null

  if (!orderId) return <Navigate to="/" replace />;   // opened directly, no state
  return <h1>Order {orderId} confirmed πŸŽ‰</h1>;
}

⚠️ Location state is fragile β€” and public-ish

State survives Back/Forward but is lost on a hard refresh or when someone pastes the URL fresh, so always guard for its absence (the ?. and fallback above). It's also stored in the browser's history β€” keep it small and never put secrets or full objects in it; pass an id and re-fetch on the other side.

Moving Through History

Pass navigate a number to walk the history stack, exactly like the browser's Back and Forward buttons β€” no path needed.

navigate(-1);   // back one entry (like clicking Back)
navigate(1);    // forward one entry
navigate(-2);   // back two entries

// A "Cancel" button that returns wherever the user came from:
function CancelButton() {
  const navigate = useNavigate();
  return <button onClick={() => navigate(-1)}>Cancel</button>;
}

Why it matters: relative history moves keep components decoupled from the app's route map. A modal's Close button that calls navigate(-1) works no matter which page opened it β€” you don't have to hard-code a destination.

πŸ’‘ Careful with hard-coded numbers

navigate(-1) assumes there's somewhere to go back to. If a user landed on the page from an external link, Back may leave your app entirely. For a reliable "up one level," prefer navigating to an explicit parent path over a blind -1 when the destination actually matters.

Protected Routes

Programmatic navigation shines for auth guards. A protected wrapper checks for a logged-in user and, if there isn't one, redirects to the login page β€” while remembering where the user was headed so you can send them back after they sign in.

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

// Declarative guard β€” redirect happens during render, safely:
function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    // Stash the attempted location so login can return here:
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  return children;
}

// Usage in the route tree:
<Route
  path="/dashboard"
  element={
    <RequireAuth>
      <Dashboard />
    </RequireAuth>
  }
/>

Now the login form reads that saved location and returns the user to it β€” the "resume where you left off" experience users expect.

function LoginForm() {
  const navigate = useNavigate();
  const location = useLocation();

  const handleLogin = async (e) => {
    e.preventDefault();
    const { user, token } = await loginUser(credentials);
    localStorage.setItem('token', token);

    // Back to the intended page, or /dashboard as a fallback:
    const from = location.state?.from?.pathname || '/dashboard';
    navigate(from, { replace: true });   // replace so Back doesn't return to login
  };

  return <form onSubmit={handleLogin}>{/* fields */}</form>;
}

βœ… Why replace here matters

After login you replace the login entry so the user's Back button skips straight past it. Without replace, tapping Back would dump an already-authenticated user right back onto the sign-in screen β€” confusing and slightly broken-feeling.

Practice & Quiz

πŸ‹οΈ Exercise 1: Redirect after submit

Goal: Write a createPost handler that awaits a save, then navigates to /posts/:id for the new post using replace.

async function handleCreate(data) {
  // TODO: await savePost, then navigate to the new post's page
}
πŸ’‘ Hint

Get navigate from useNavigate(). Await the save, read the returned id, then call navigate(`/posts/${id}`, { replace: true }).

βœ… Solution
function NewPost() {
  const navigate = useNavigate();

  const handleCreate = async (data) => {
    const { id } = await savePost(data);
    navigate(`/posts/${id}`, { replace: true });
  };

  return <PostEditor onSave={handleCreate} />;
}

πŸ‹οΈ Exercise 2: Login returns you home

Goal: After a successful login, send the user to location.state?.from if present, else /dashboard β€” and keep login out of history.

βœ… Solution
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });

The replace keeps the login page off the stack; the fallback covers users who came to /login directly.

🎯 Quick Quiz

Question 1: When should you prefer useNavigate over a <Link>?

Question 2: What does navigate('/dashboard', { replace: true }) do to history?

Question 3: You need to redirect during render because a user isn't logged in. Best choice?

Best Practices & Pitfalls

βœ… Do

  • Default to <Link> for user-clickable destinations; use useNavigate for code-driven moves
  • Redirect with { replace: true } after logins and completed one-way flows
  • Wrap navigation after await in try/catch and handle failures gracefully
  • Pass a small id in location state and re-fetch, rather than a whole object
  • Use <Navigate> for redirects that happen during render

❌ Don't

  • Call navigate() in the render body β€” put it in a handler or effect
  • Redirect to an error page on every failure and lose the user's input
  • Rely on location state surviving a refresh β€” always guard for its absence
  • Blindly navigate(-1) when a specific parent path is what you actually mean

⚠️ Pass minimal state

// ❌ Ships an entire object through history β€” bloated and fragile
navigate('/details', { state: { item } });

// βœ… Pass an id; re-fetch on the destination
navigate('/details', { state: { id: item.id } });

Summary

πŸŽ‰ Key Takeaways

  • useNavigate() returns a navigate function for code-driven route changes
  • Use a <Link> when the user clicks; useNavigate when your logic decides
  • Redirect after async work in a handler, and handle failures without losing input
  • { replace: true } keeps login and one-way screens out of the Back stack
  • Location state ships hidden data but is lost on refresh β€” guard for it and keep it small
  • Pass a number to walk history; use <Navigate> for redirects during render

πŸ“š Additional Resources

πŸš€ What's Next?

You've finished the Advanced Routing track β€” your app can nest layouts, read the URL, and steer itself. Next we turn to making it fast: React.memo & useMemo, the tools for skipping unnecessary re-renders and expensive recalculations.

πŸŽ‰ Routing mastered!

Nested layouts, dynamic params, and programmatic navigation β€” you now have every routing tool a production React app needs.