Loading
Route components must never be statically imported. Use dynamic imports paired with framework-level suspense boundaries to enable automatic code splitting.
// routes.ts
import { lazy, Suspense } from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const NotFound = lazy(() => import('./pages/NotFound'));
export const routeConfig: RouteObject[] = [
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/dashboard',
element: (
<Suspense fallback={<RouteLoader />}>
<Dashboard />
</Suspense>
),
loader: async () => {
const { fetchDashboardMetrics } = await import('./api/dashboard');
return fetchDashboardMetrics();
},
},
{
path: '/settings',
element: (
<Suspense fallback={<RouteLoader />}>
<Settings />
</Suspense>
),
loader: async ({ params }) => {
const { fetchUserPreferences } = await import('./api/settings');
return fetchUserPreferences(params.userId);
},
},
{
path: '*',
element: (
<Suspense fallback={<RouteLoader />}>
<NotFound />
</Suspense>
),
},
];
Architecture Rationale: Dynamic imports enable Vite/Webpack to generate separate chunks per route. Loaders execute before component mounting, preventing waterfall fetches and ensuring data availability on render. Suspense provides a deterministic loading state without manual state flags.
Step 2: Implement Route-Level State Isolation
Global stores should never hold route-specific state. Route parameters and loader data must be consumed via route-level hooks.
// pages/Dashboard.tsx
import { useLoaderData, useNavigation } from 'react-router-dom';
import { DashboardMetrics } from '../types';
export default function Dashboard() {
const metrics = useLoaderData<DashboardMetrics>();
const navigation = useNavigation();
if (navigation.state === 'loading') {
return <div aria-live="polite">Updating dashboard...</div>;
}
return (
<main role="main" aria-label="Dashboard Overview">
<h1>Dashboard</h1>
<MetricsGrid data={metrics} />
</main>
);
}
Architecture Rationale: Route-level state isolation prevents cross-route state leakage. useLoaderData guarantees data consistency with the current URL. Navigation state tracking enables optimistic UI updates without global dispatch overhead.
Step 3: Add Async Navigation Guards
Synchronous guards block the main thread and degrade INP. Use async validation with explicit fallback routing.
// guards.ts
import { redirect } from 'react-router-dom';
export async function requireAuth() {
const { verifySession } = await import('./api/auth');
const session = await verifySession();
if (!session?.valid) {
throw redirect('/login?redirect=' + encodeURIComponent(window.location.pathname));
}
return session;
}
// routes.ts (updated)
{
path: '/dashboard',
loader: async () => {
await requireAuth();
const { fetchDashboardMetrics } = await import('./api/dashboard');
return fetchDashboardMetrics();
},
// ...
}
Architecture Rationale: Async guards execute off the critical rendering path. Throwing redirect integrates with the router's error boundary system, preventing component mount until authorization resolves. Dynamic imports inside guards keep auth logic out of the initial bundle.
Browsers do not automatically restore scroll position or manage focus on route changes. Explicit handling is required for accessibility and UX consistency.
// ScrollRestoration.tsx
import { useEffect } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
export function ScrollRestoration() {
const location = useLocation();
const navigationType = useNavigationType();
useEffect(() => {
if (navigationType === 'POP') {
const savedPosition = sessionStorage.getItem(`scroll-${location.key}`);
if (savedPosition) {
window.scrollTo(0, parseInt(savedPosition, 10));
return;
}
}
window.scrollTo(0, 0);
}, [location, navigationType]);
useEffect(() => {
const handleScroll = () => {
sessionStorage.setItem(`scroll-${location.key}`, String(window.scrollY));
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [location]);
useEffect(() => {
const mainElement = document.querySelector('main');
if (mainElement) {
mainElement.focus();
}
}, [location]);
return null;
}
Architecture Rationale: sessionStorage keyed by location.key preserves scroll state across forward/back navigation without polluting global state. Passive scroll listeners prevent main-thread blocking. Focus management targets semantic landmarks, satisfying WCAG 2.4.3 requirements.
Step 5: Integrate Route-Level Analytics
Page views must fire after navigation commits, not on component mount. Router lifecycle hooks guarantee accurate tracking.
// analytics.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function useRouteAnalytics() {
const location = useLocation();
useEffect(() => {
const track = async () => {
const { trackPageView } = await import('./lib/analytics');
await trackPageView(location.pathname, location.search);
};
track();
}, [location]);
}
Architecture Rationale: useLocation updates after the router commits the navigation. Dynamic analytics import keeps tracking SDKs out of the initial bundle. Async execution prevents analytics network calls from blocking rendering.
Pitfall Guide
1. Synchronous Route Guards Blocking Navigation
Why it breaks: Synchronous auth checks or heavy computations inside route guards block the main thread, causing INP spikes and frozen UI during navigation.
Best practice: Always use async loaders. Throw redirect or json responses to let the router handle state transitions. Keep guard logic under 16ms.
2. Tying Global State to Route Parameters
Why it breaks: Storing route params in Redux/Zustand/Pinia creates synchronization drift. URL updates without store updates, or vice versa, cause stale UI and hydration mismatches.
Best practice: Derive state from the URL. Use useSearchParams or route loaders as the single source of truth. Global stores should only hold cross-cutting concerns (auth, theme, feature flags).
Why it breaks: Users land at arbitrary scroll positions after navigation. Screen readers announce content without focus context. Accessibility audits fail.
Best practice: Implement sessionStorage-backed scroll restoration keyed by navigation history entries. Move focus to semantic landmarks (<main>, <h1>) on route change.
4. Unbounded Route-Level Data Fetching
Why it breaks: Fetching inside useEffect triggers waterfall patterns, duplicate requests on fast navigation, and race conditions when parameters change rapidly.
Best practice: Use route loaders or data fetching libraries with request deduplication and cancellation. Cache responses by URL signature. Set explicit timeout and retry policies.
5. Missing Concurrent Navigation Handling
Why it breaks: Rapid clicks or back/forward navigation trigger multiple route transitions. Later navigation overwrites earlier state, causing partial renders or ghost components.
Best practice: Leverage framework-level concurrent routing (React Router v6+, Vue Router 4+). Use abort controllers in loaders. Avoid manual state updates during pending navigation.
6. Overlooking Deep Linking and History State Consistency
Why it breaks: Applications that mutate window.history directly bypass router internals, breaking back-button behavior and analytics tracking.
Best practice: Never call history.pushState or replaceState manually. Use router navigation APIs (navigate, useNavigate). Let the router manage history entries and state objects.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small internal tool (<10 routes) | Framework-native router with static imports | Simpler maintenance, negligible bundle overhead | Low infrastructure cost, faster dev cycle |
| Data-heavy dashboard (15-50 routes) | Route loaders + lazy loading + scroll restoration | Prevents waterfalls, ensures data consistency, meets accessibility standards | Moderate build complexity, high UX ROI |
| E-commerce or marketing site (SEO critical) | Hybrid routing (SSR/SSG + client hydration) | Guarantees crawlability, reduces client-side routing overhead | Higher hosting cost, improved conversion |
| Real-time collaborative app | Client routing + WebSocket state sync + optimistic UI | Routing handles navigation; WS handles data; prevents stale route cache | Increased engineering effort, essential for sync integrity |
Configuration Template
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
analytics: ['./lib/analytics.ts'],
auth: ['./api/auth.ts'],
},
},
},
},
optimizeDeps: {
include: ['react-router-dom'],
},
});
// router.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { routeConfig } from './routes';
import { ScrollRestoration } from './components/ScrollRestoration';
import { useRouteAnalytics } from './hooks/useRouteAnalytics';
import { ErrorBoundary } from './components/ErrorBoundary';
const router = createBrowserRouter(routeConfig, {
future: {
v7_startTransition: true,
v7_relativeSplatPath: true,
},
});
export function AppRouter() {
useRouteAnalytics();
return (
<RouterProvider
router={router}
fallbackElement={<div>Loading...</div>}
errorElement={<ErrorBoundary />}
>
<ScrollRestoration />
</RouterProvider>
);
}
Quick Start Guide
- Initialize project with
npm create vite@latest my-app -- --template react-ts and install react-router-dom.
- Replace static route imports with dynamic
import() and wrap components in <Suspense fallback={<RouteLoader />}>.
- Move data fetching from
useEffect to route loader functions; access data via useLoaderData.
- Add
ScrollRestoration component and useRouteAnalytics hook to your root router wrapper.
- Run
npm run build and verify chunk splitting with npx vite-bundle-visualizer; confirm route chunks load only on navigation.