Programmatic Navigation

Taking Control of Navigation Through Code

Welcome to Programmatic Navigation!

Imagine you're a pilot with both manual controls and an autopilot system. While manual controls (like Link components) are great for direct navigation, sometimes you need programmatic control to navigate based on conditions, after async operations, or in response to complex logic. Today, we'll master the art of navigation through code!

graph TB A[User Action] --> B{Navigation Decision} B --> |Simple| C[Link Component] B --> |Complex| D[Programmatic Navigation] D --> E[useNavigate Hook] E --> F[navigate Function] F --> G[Conditional Navigation] F --> H[Navigation with State] F --> I[Replace History] F --> J[Relative Navigation] style D fill:#ff9999 style E fill:#99ff99 style F fill:#9999ff

Navigation Hook Fundamentals

1. useNavigate Hook Basics

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

function NavigationExamples() {
  const navigate = useNavigate();
  
  // Basic navigation
  const goToHome = () => {
    navigate('/');
  };
  
  // Navigation with absolute path
  const goToAbout = () => {
    navigate('/about');
  };
  
  // Navigation with relative path
  const goToSettings = () => {
    navigate('settings'); // Relative to current path
  };
  
  // Navigation with replace
  const redirectToLogin = () => {
    navigate('/login', { replace: true });
  };
  
  // Navigation with state
  const goToProfile = () => {
    navigate('/profile', { 
      state: { 
        from: 'dashboard',
        timestamp: Date.now()
      } 
    });
  };
  
  // Go back/forward in history
  const goBack = () => navigate(-1);
  const goForward = () => navigate(1);
  const goBackTwo = () => navigate(-2);
  
  return (
    <div>
      <button onClick={goToHome}>Home</button>
      <button onClick={goToAbout}>About</button>
      <button onClick={goToSettings}>Settings</button>
      <button onClick={redirectToLogin}>Login (Replace)</button>
      <button onClick={goToProfile}>Profile (With State)</button>
      <button onClick={goBack}>Back</button>
      <button onClick={goForward}>Forward</button>
    </div>
  );
}

2. Navigation Options

function AdvancedNavigation() {
  const navigate = useNavigate();
  
  // Navigation with all options
  const navigateWithOptions = () => {
    navigate('/dashboard', {
      replace: true,  // Replace current entry in history stack
      state: {        // Pass state to the new location
        from: location.pathname,
        filter: 'active',
        page: 1
      },
      preventScrollReset: true  // Prevent scroll position reset
    });
  };
  
  // Conditional navigation
  const conditionalNavigate = (isLoggedIn) => {
    if (isLoggedIn) {
      navigate('/dashboard');
    } else {
      navigate('/login', {
        state: { returnTo: '/dashboard' }
      });
    }
  };
  
  // Navigation with search params
  const navigateWithSearch = () => {
    navigate({
      pathname: '/search',
      search: '?q=react&category=tutorials'
    });
  };
  
  // Navigation with hash
  const navigateWithHash = () => {
    navigate({
      pathname: '/docs',
      hash: '#getting-started'
    });
  };
  
  // Complete navigation object
  const navigateComplete = () => {
    navigate({
      pathname: '/products',
      search: '?sort=price&order=asc',
      hash: '#reviews',
    }, {
      state: { fromPromotion: true },
      replace: false
    });
  };
  
  return (
    <div>
      <button onClick={navigateWithOptions}>
        Navigate with Options
      </button>
      <button onClick={() => conditionalNavigate(true)}>
        Conditional Navigate
      </button>
      <button onClick={navigateWithSearch}>
        Navigate with Search
      </button>
      <button onClick={navigateWithHash}>
        Navigate with Hash
      </button>
      <button onClick={navigateComplete}>
        Complete Navigation
      </button>
    </div>
  );
}

Common Navigation Patterns

1. After Form Submission

function RegistrationForm() {
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    
    try {
      const result = await registerUser(formData);
      
      // Navigate on success
      navigate('/welcome', {
        state: { 
          user: result.user,
          isNewUser: true 
        }
      });
    } catch (error) {
      // Handle error - maybe navigate to error page
      navigate('/error', {
        state: { 
          error: error.message,
          from: '/register'
        }
      });
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}

2. After Authentication

function LoginForm() {
  const navigate = useNavigate();
  const location = useLocation();
  const [credentials, setCredentials] = useState({
    email: '',
    password: ''
  });
  
  const handleLogin = async (e) => {
    e.preventDefault();
    
    try {
      const { user, token } = await loginUser(credentials);
      
      // Store auth token
      localStorage.setItem('token', token);
      
      // Redirect to intended destination or dashboard
      const from = location.state?.from?.pathname || '/dashboard';
      navigate(from, { replace: true });
      
    } catch (error) {
      // Navigate to login error page
      navigate('/login/error', {
        state: { 
          error: error.message,
          credentials: { email: credentials.email }
        }
      });
    }
  };
  
  return (
    <form onSubmit={handleLogin}>
      {/* Login form fields */}
    </form>
  );
}

// Protected route component
function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();
  const navigate = useNavigate();
  
  useEffect(() => {
    if (!user) {
      // Redirect to login with return location
      navigate('/login', {
        state: { from: location },
        replace: true
      });
    }
  }, [user, navigate, location]);
  
  return user ? children : null;
}

3. Wizard/Multi-step Navigation

function CheckoutWizard() {
  const navigate = useNavigate();
  const [currentStep, setCurrentStep] = useState(1);
  const [wizardData, setWizardData] = useState({
    shipping: null,
    payment: null,
    review: null
  });
  
  const steps = [
    { path: 'shipping', component: ShippingForm },
    { path: 'payment', component: PaymentForm },
    { path: 'review', component: OrderReview },
    { path: 'confirmation', component: OrderConfirmation }
  ];
  
  const goToStep = (stepIndex) => {
    navigate(`/checkout/${steps[stepIndex].path}`, {
      state: { wizardData }
    });
    setCurrentStep(stepIndex + 1);
  };
  
  const handleNext = (stepData) => {
    const updatedData = {
      ...wizardData,
      [steps[currentStep - 1].path]: stepData
    };
    
    setWizardData(updatedData);
    
    if (currentStep < steps.length) {
      goToStep(currentStep);
    } else {
      submitOrder(updatedData);
    }
  };
  
  const handleBack = () => {
    if (currentStep > 1) {
      goToStep(currentStep - 2);
    }
  };
  
  const submitOrder = async (orderData) => {
    try {
      const result = await createOrder(orderData);
      navigate('/order-success', {
        state: { orderDetails: result },
        replace: true // Prevent going back to checkout
      });
    } catch (error) {
      navigate('/checkout/error', {
        state: { error, orderData }
      });
    }
  };
  
  return (
    <div className="checkout-wizard">
      <StepIndicator 
        steps={steps} 
        currentStep={currentStep} 
      />
      
      <Routes>
        {steps.map((step, index) => (
          <Route
            key={step.path}
            path={step.path}
            element={
              <step.component
                data={wizardData[step.path]}
                onNext={handleNext}
                onBack={handleBack}
                isLastStep={index === steps.length - 1}
              />
            }
          />
        ))}
      </Routes>
    </div>
  );
}

Advanced Navigation Patterns

1. Navigation Guards

// Navigation guard hook
function useNavigationGuard(
  message = 'You have unsaved changes. Are you sure you want to leave?',
  when = true
) {
  const navigate = useNavigate();
  const [isBlocking, setIsBlocking] = useState(false);
  const [pendingNavigation, setPendingNavigation] = useState(null);
  
  useEffect(() => {
    if (!when) return;
    
    // Create navigation interceptor
    const unblock = window.onbeforeunload = (e) => {
      e.preventDefault();
      e.returnValue = message;
      return message;
    };
    
    return () => {
      window.onbeforeunload = null;
    };
  }, [when, message]);
  
  const confirmNavigation = () => {
    if (pendingNavigation) {
      const { to, options } = pendingNavigation;
      setPendingNavigation(null);
      setIsBlocking(false);
      navigate(to, options);
    }
  };
  
  const cancelNavigation = () => {
    setPendingNavigation(null);
    setIsBlocking(false);
  };
  
  const guardedNavigate = (to, options) => {
    if (when) {
      setIsBlocking(true);
      setPendingNavigation({ to, options });
    } else {
      navigate(to, options);
    }
  };
  
  return {
    isBlocking,
    confirmNavigation,
    cancelNavigation,
    guardedNavigate
  };
}

// Usage in a form component
function EditForm() {
  const [isDirty, setIsDirty] = useState(false);
  const {
    isBlocking,
    confirmNavigation,
    cancelNavigation,
    guardedNavigate
  } = useNavigationGuard(
    'You have unsaved changes. Do you want to discard them?',
    isDirty
  );
  
  const handleSave = async (data) => {
    try {
      await saveData(data);
      setIsDirty(false);
      guardedNavigate('/success');
    } catch (error) {
      // Handle error
    }
  };
  
  return (
    <div>
      <form onChange={() => setIsDirty(true)}>
        {/* Form fields */}
      </form>
      
      {isBlocking && (
        <Modal>
          <p>You have unsaved changes. Do you want to leave?</p>
          <button onClick={confirmNavigation}>Leave</button>
          <button onClick={cancelNavigation}>Stay</button>
        </Modal>
      )}
    </div>
  );
}

2. Navigation with Animation

// Animated navigation hook
function useAnimatedNavigation() {
  const navigate = useNavigate();
  const [isAnimating, setIsAnimating] = useState(false);
  
  const animatedNavigate = async (to, options = {}) => {
    setIsAnimating(true);
    
    // Start exit animation
    await new Promise(resolve => {
      document.body.classList.add('page-exit');
      setTimeout(resolve, 300); // Match CSS transition duration
    });
    
    // Navigate
    navigate(to, options);
    
    // Start enter animation
    await new Promise(resolve => {
      document.body.classList.remove('page-exit');
      document.body.classList.add('page-enter');
      setTimeout(() => {
        document.body.classList.remove('page-enter');
        setIsAnimating(false);
        resolve();
      }, 300);
    });
  };
  
  return { animatedNavigate, isAnimating };
}

// Usage component
function AnimatedLink({ to, children, ...props }) {
  const { animatedNavigate, isAnimating } = useAnimatedNavigation();
  
  const handleClick = (e) => {
    e.preventDefault();
    if (!isAnimating) {
      animatedNavigate(to);
    }
  };
  
  return (
    <a 
      href={to} 
      onClick={handleClick}
      className={isAnimating ? 'animating' : ''}
      {...props}
    >
      {children}
    </a>
  );
}

3. Navigation Queue

// Navigation queue for sequential navigation
function useNavigationQueue() {
  const navigate = useNavigate();
  const [queue, setQueue] = useState([]);
  const [isProcessing, setIsProcessing] = useState(false);
  
  const enqueueNavigation = (to, options = {}) => {
    setQueue(prev => [...prev, { to, options }]);
  };
  
  const processQueue = async () => {
    if (isProcessing || queue.length === 0) return;
    
    setIsProcessing(true);
    const { to, options } = queue[0];
    
    try {
      // Add any pre-navigation logic here
      await new Promise(resolve => setTimeout(resolve, 500));
      
      navigate(to, options);
      
      // Remove processed item
      setQueue(prev => prev.slice(1));
    } finally {
      setIsProcessing(false);
    }
  };
  
  useEffect(() => {
    processQueue();
  }, [queue, isProcessing]);
  
  return { enqueueNavigation, queueLength: queue.length };
}

// Usage example
function SequentialNavigator() {
  const { enqueueNavigation, queueLength } = useNavigationQueue();
  
  const navigateSequentially = () => {
    enqueueNavigation('/step1');
    enqueueNavigation('/step2', { state: { data: 'test' } });
    enqueueNavigation('/step3');
    enqueueNavigation('/complete', { replace: true });
  };
  
  return (
    <div>
      <button onClick={navigateSequentially}>
        Start Sequential Navigation
      </button>
      {queueLength > 0 && (
        <p>Navigation queue: {queueLength} remaining</p>
      )}
    </div>
  );
}

Conditional Navigation

1. Role-based Navigation

function RoleBasedNavigator() {
  const navigate = useNavigate();
  const { user } = useAuth();
  
  const navigateByRole = (destination) => {
    // Define role-based routing rules
    const routeMap = {
      admin: {
        dashboard: '/admin/dashboard',
        settings: '/admin/settings',
        users: '/admin/users',
        default: '/admin'
      },
      manager: {
        dashboard: '/manager/dashboard',
        settings: '/manager/settings',
        reports: '/manager/reports',
        default: '/manager'
      },
      user: {
        dashboard: '/user/dashboard',
        settings: '/user/settings',
        default: '/user'
      }
    };
    
    const userRole = user?.role || 'user';
    const roleRoutes = routeMap[userRole] || routeMap.user;
    const route = roleRoutes[destination] || roleRoutes.default;
    
    navigate(route);
  };
  
  const handleDashboardClick = () => {
    navigateByRole('dashboard');
  };
  
  const handleSettingsClick = () => {
    navigateByRole('settings');
  };
  
  return (
    <nav>
      <button onClick={handleDashboardClick}>Dashboard</button>
      <button onClick={handleSettingsClick}>Settings</button>
      {user?.role === 'admin' && (
        <button onClick={() => navigateByRole('users')}>
          Manage Users
        </button>
      )}
      {user?.role === 'manager' && (
        <button onClick={() => navigateByRole('reports')}>
          View Reports
        </button>
      )}
    </nav>
  );
}

2. Feature Flag Navigation

function FeatureFlagNavigator() {
  const navigate = useNavigate();
  const { features } = useFeatureFlags();
  
  const navigateToFeature = (featureName, defaultPath = '/') => {
    const featureRoutes = {
      newDashboard: '/dashboard-v2',
      analytics: '/analytics',
      betaTools: '/beta/tools',
      experiments: '/experiments'
    };
    
    if (features[featureName]) {
      navigate(featureRoutes[featureName]);
    } else {
      // Navigate to default or show unavailable message
      navigate(defaultPath, {
        state: { 
          unavailableFeature: featureName,
          message: 'This feature is not available yet'
        }
      });
    }
  };
  
  return (
    <div>
      <button onClick={() => navigateToFeature('newDashboard', '/dashboard')}>
        Dashboard
      </button>
      <button onClick={() => navigateToFeature('analytics', '/reports')}>
        Analytics
      </button>
      <button onClick={() => navigateToFeature('betaTools')}>
        Beta Tools
      </button>
    </div>
  );
}

3. Dynamic Path Resolution

function DynamicNavigator() {
  const navigate = useNavigate();
  const { config } = useAppConfig();
  
  const navigateToDynamicPath = async (pathKey) => {
    // Resolve path from configuration
    const pathResolver = {
      home: () => config.homePath || '/',
      profile: (userId) => `/users/${userId || 'me'}`,
      document: async (docId) => {
        const doc = await fetchDocument(docId);
        return `/docs/${doc.type}/${doc.id}`;
      },
      search: (query) => `/search?q=${encodeURIComponent(query)}`,
      category: (cat, subcat) => {
        let path = `/categories/${cat}`;
        if (subcat) path += `/${subcat}`;
        return path;
      }
    };
    
    try {
      const resolver = pathResolver[pathKey];
      if (!resolver) throw new Error(`Unknown path: ${pathKey}`);
      
      const path = await resolver(...Array.from(arguments).slice(1));
      navigate(path);
    } catch (error) {
      navigate('/error', { 
        state: { error: error.message } 
      });
    }
  };
  
  return (
    <div>
      <button onClick={() => navigateToDynamicPath('home')}>
        Home
      </button>
      <button onClick={() => navigateToDynamicPath('profile', '123')}>
        User Profile
      </button>
      <button onClick={() => navigateToDynamicPath('document', 'doc-456')}>
        Open Document
      </button>
      <button onClick={() => navigateToDynamicPath('category', 'electronics', 'phones')}>
        Electronics > Phones
      </button>
    </div>
  );
}

Navigation with State Management

1. Navigation History Management

// Custom navigation history manager
function useNavigationHistory(maxHistory = 10) {
  const navigate = useNavigate();
  const location = useLocation();
  const [history, setHistory] = useState([]);
  
  // Track navigation history
  useEffect(() => {
    setHistory(prev => {
      const newHistory = [
        { path: location.pathname, timestamp: Date.now() },
        ...prev
      ].slice(0, maxHistory);
      return newHistory;
    });
  }, [location, maxHistory]);
  
  const navigateToHistoryItem = (index) => {
    if (history[index]) {
      navigate(history[index].path);
    }
  };
  
  const navigateBack = (steps = 1) => {
    if (history[steps]) {
      navigate(history[steps].path);
    } else {
      navigate(-steps);
    }
  };
  
  const clearHistory = () => {
    setHistory([]);
  };
  
  return {
    history,
    navigateToHistoryItem,
    navigateBack,
    clearHistory
  };
}

// Usage component
function HistoryNavigator() {
  const { 
    history, 
    navigateToHistoryItem, 
    navigateBack, 
    clearHistory 
  } = useNavigationHistory();
  
  return (
    <div className="history-navigator">
      <h3>Navigation History</h3>
      <ul>
        {history.map((item, index) => (
          <li key={item.timestamp}>
            <button onClick={() => navigateToHistoryItem(index)}>
              {item.path} - {new Date(item.timestamp).toLocaleTimeString()}
            </button>
          </li>
        ))}
      </ul>
      <button onClick={() => navigateBack(2)}>Go Back 2 Steps</button>
      <button onClick={clearHistory}>Clear History</button>
    </div>
  );
}

2. Navigation with Redux/Context

// Navigation context
const NavigationContext = createContext();

function NavigationProvider({ children }) {
  const navigate = useNavigate();
  const location = useLocation();
  const [navigationState, setNavigationState] = useState({
    isNavigating: false,
    pendingRoute: null,
    navigationStack: []
  });
  
  const startNavigation = (to, options = {}) => {
    setNavigationState(prev => ({
      ...prev,
      isNavigating: true,
      pendingRoute: to
    }));
    
    // Perform navigation after state update
    setTimeout(() => {
      navigate(to, options);
      setNavigationState(prev => ({
        ...prev,
        isNavigating: false,
        pendingRoute: null,
        navigationStack: [...prev.navigationStack, location.pathname]
      }));
    }, 0);
  };
  
  const navigateWithState = (to, state = {}, options = {}) => {
    startNavigation(to, { ...options, state });
  };
  
  const navigateConditional = (condition, truePath, falsePath) => {
    const path = condition ? truePath : falsePath;
    startNavigation(path);
  };
  
  const value = {
    ...navigationState,
    navigate: startNavigation,
    navigateWithState,
    navigateConditional,
    currentPath: location.pathname
  };
  
  return (
    <NavigationContext.Provider value={value}>
      {children}
    </NavigationContext.Provider>
  );
}

// Usage with context
function NavigationButton() {
  const { navigate, isNavigating } = useContext(NavigationContext);
  
  return (
    <button 
      onClick={() => navigate('/dashboard')}
      disabled={isNavigating}
    >
      {isNavigating ? 'Navigating...' : 'Go to Dashboard'}
    </button>
  );
}

Navigation Error Handling

1. Safe Navigation

// Safe navigation hook
function useSafeNavigation() {
  const navigate = useNavigate();
  const [error, setError] = useState(null);
  
  const safeNavigate = async (to, options = {}) => {
    try {
      setError(null);
      
      // Validate navigation target
      if (typeof to !== 'string' && typeof to !== 'object') {
        throw new Error('Invalid navigation target');
      }
      
      // Check if route exists (custom validation)
      const isValidRoute = await validateRoute(to);
      if (!isValidRoute) {
        throw new Error(`Route does not exist: ${to}`);
      }
      
      // Perform navigation
      navigate(to, options);
      
    } catch (err) {
      setError(err);
      
      // Navigate to error page
      navigate('/error', {
        state: { 
          error: err.message,
          attemptedPath: to
        }
      });
    }
  };
  
  return { safeNavigate, error };
}

// Usage example
function SafeNavigationComponent() {
  const { safeNavigate, error } = useSafeNavigation();
  
  const handleNavigation = () => {
    safeNavigate('/potentially-invalid-route');
  };
  
  return (
    <div>
      <button onClick={handleNavigation}>
        Navigate Safely
      </button>
      {error && (
        <div className="error-message">
          Navigation failed: {error.message}
        </div>
      )}
    </div>
  );
}

2. Navigation Retry

// Navigation with retry capability
function useRetryNavigation(maxRetries = 3) {
  const navigate = useNavigate();
  const [attempts, setAttempts] = useState(0);
  const [lastError, setLastError] = useState(null);
  
  const navigateWithRetry = async (to, options = {}) => {
    const attemptNavigation = async (currentAttempt) => {
      try {
        setAttempts(currentAttempt);
        
        // Simulate potential failure
        if (Math.random() < 0.3 && currentAttempt < maxRetries) {
          throw new Error('Navigation failed');
        }
        
        navigate(to, options);
        setAttempts(0);
        setLastError(null);
        
      } catch (error) {
        setLastError(error);
        
        if (currentAttempt < maxRetries) {
          // Exponential backoff
          const delay = Math.pow(2, currentAttempt) * 1000;
          await new Promise(resolve => setTimeout(resolve, delay));
          await attemptNavigation(currentAttempt + 1);
        } else {
          // Max retries reached
          navigate('/error', {
            state: { 
              error: error.message,
              attempts: currentAttempt,
              destination: to
            }
          });
        }
      }
    };
    
    await attemptNavigation(1);
  };
  
  return { 
    navigateWithRetry, 
    attempts, 
    lastError,
    isRetrying: attempts > 0 
  };
}

Testing Programmatic Navigation

// Testing navigation
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

// Mock navigate function
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: () => mockNavigate
}));

describe('Programmatic Navigation', () => {
  beforeEach(() => {
    mockNavigate.mockClear();
  });
  
  test('navigates on button click', () => {
    const TestComponent = () => {
      const navigate = useNavigate();
      
      const handleClick = () => {
        navigate('/dashboard');
      };
      
      return <button onClick={handleClick}>Go to Dashboard</button>;
    };
    
    render(
      <MemoryRouter>
        <TestComponent />
      </MemoryRouter>
    );
    
    fireEvent.click(screen.getByText('Go to Dashboard'));
    expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
  });
  
  test('navigates with state', () => {
    const TestComponent = () => {
      const navigate = useNavigate();
      
      const handleClick = () => {
        navigate('/profile', { 
          state: { from: 'home' } 
        });
      };
      
      return <button onClick={handleClick}>Go to Profile</button>;
    };
    
    render(
      <MemoryRouter>
        <TestComponent />
      </MemoryRouter>
    );
    
    fireEvent.click(screen.getByText('Go to Profile'));
    expect(mockNavigate).toHaveBeenCalledWith('/profile', {
      state: { from: 'home' }
    });
  });
  
  test('conditional navigation', () => {
    const TestComponent = ({ isLoggedIn }) => {
      const navigate = useNavigate();
      
      const handleClick = () => {
        if (isLoggedIn) {
          navigate('/dashboard');
        } else {
          navigate('/login');
        }
      };
      
      return <button onClick={handleClick}>Navigate</button>;
    };
    
    const { rerender } = render(
      <MemoryRouter>
        <TestComponent isLoggedIn={false} />
      </MemoryRouter>
    );
    
    fireEvent.click(screen.getByText('Navigate'));
    expect(mockNavigate).toHaveBeenCalledWith('/login');
    
    mockNavigate.mockClear();
    
    rerender(
      <MemoryRouter>
        <TestComponent isLoggedIn={true} />
      </MemoryRouter>
    );
    
    fireEvent.click(screen.getByText('Navigate'));
    expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
  });
});

// Integration test with actual navigation
describe('Navigation Integration', () => {
  test('navigates between routes', () => {
    const Home = () => {
      const navigate = useNavigate();
      return (
        <div>
          <h1>Home</h1>
          <button onClick={() => navigate('/about')}>
            Go to About
          </button>
        </div>
      );
    };
    
    const About = () => <h1>About Page</h1>;
    
    render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </MemoryRouter>
    );
    
    expect(screen.getByText('Home')).toBeInTheDocument();
    
    fireEvent.click(screen.getByText('Go to About'));
    
    expect(screen.getByText('About Page')).toBeInTheDocument();
  });
});

Practice Exercises

Exercise 1: Build an Authentication Flow

Create a complete authentication flow with:

  • Login form with navigation to dashboard on success
  • Protected routes that redirect to login
  • Logout functionality that clears state and redirects
  • Password reset flow with email verification
  • Remember "intended destination" after login redirect

Exercise 2: Multi-step Form Wizard

Implement a form wizard with:

  • Multiple steps with validation
  • Navigation guards to prevent skipping steps
  • Progress saving between steps
  • Ability to go back and edit previous steps
  • Confirmation before submission

Exercise 3: Search with Filters

Create a search interface that:

  • Updates URL with search parameters
  • Navigates to results page on search
  • Maintains filter state in URL
  • Implements pagination with navigation
  • Handles empty results with redirect to suggestions

Best Practices

1. Always Handle Navigation Errors

try {
  await someAsyncOperation();
  navigate('/success');
} catch (error) {
  navigate('/error', { state: { error } });
}

2. Use Replace for Redirects

// Prevent back button to login after auth
navigate('/dashboard', { replace: true });

3. Pass Minimal State

// Good: Pass only necessary data
navigate('/details', { state: { id: item.id } });

// Avoid: Passing entire objects
navigate('/details', { state: { item } });

Key Takeaways