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>
);
}
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
- useNavigate hook provides programmatic navigation control
- Navigation can include state, replace history, or be relative
- Always handle navigation errors and edge cases
- Use navigation guards for forms with unsaved changes
- Test navigation thoroughly, including error scenarios
- Consider animation and loading states during navigation