Building React router from scratch
Technical Overview
Building a React router from scratch involves understanding browser history management, route matching algorithms, and component rendering patterns. A custom router provides deep insights into how routing works and allows you to create tailored solutions for specific use cases. The core components include a history manager for navigation, a route matcher for URL-to-component mapping, and navigation components for user interaction.
Architecture & Approach
Our custom router will follow a modular architecture with three main layers: History Management (handling browser navigation), Route Resolution (matching URLs to components), and Component Rendering (displaying the matched components). We’ll use React Context for state management, hooks for navigation logic, and a component-based API similar to popular routing libraries for familiarity.
Implementation Details
Core Components
History Manager:
interface Location {
pathname: string;
search: string;
hash: string;
state: any;
}
interface History {
location: Location;
push: (path: string, state?: any) => void;
replace: (path: string, state?: any) => void;
goBack: () => void;
goForward: () => void;
listen: (listener: (location: Location) => void) => () => void;
}
const createBrowserHistory = (): History => {
let listeners: ((location: Location) => void)[] = [];
let location: Location = {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
state: window.history.state,
};
const notifyListeners = () => {
listeners.forEach((listener) => listener(location));
};
const updateLocation = (path: string, state?: any, method: 'push' | 'replace' = 'push') => {
const url = new URL(path, window.location.origin);
location = {
pathname: url.pathname,
search: url.search,
hash: url.hash,
state,
};
if (method === 'push') {
window.history.pushState(state, '', path);
} else {
window.history.replaceState(state, '', path);
}
notifyListeners();
};
window.addEventListener('popstate', () => {
location = {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
state: window.history.state,
};
notifyListeners();
});
return {
location,
push: (path, state) => updateLocation(path, state, 'push'),
replace: (path, state) => updateLocation(path, state, 'replace'),
goBack: () => window.history.back(),
goForward: () => window.history.forward(),
listen: (listener) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
};
};
Router Context:
interface RouterContextValue {
history: History;
location: Location;
}
const RouterContext = React.createContext<RouterContextValue | null>(null);
export const useRouter = (): RouterContextValue => {
const context = useContext(RouterContext);
if (!context) {
throw new Error('useRouter must be used within a Router');
}
return context;
};
Route Matching:
interface RouteConfig {
path: string;
component: React.ComponentType<any>;
exact?: boolean;
}
const matchPath = (pathname: string, route: RouteConfig): boolean => {
const { path, exact = false } = route;
if (exact) {
return pathname === path;
}
return pathname.startsWith(path);
};
const matchRoute = (pathname: string, routes: RouteConfig[]): RouteConfig | null => {
for (const route of routes) {
if (matchPath(pathname, route)) {
return route;
}
}
return null;
};
Configuration
Main Router Component:
interface RouterProps {
children: React.ReactNode;
routes?: RouteConfig[];
}
export const Router: React.FC<RouterProps> = ({ children, routes = [] }) => {
const [history] = useState(() => createBrowserHistory());
const [location, setLocation] = useState(history.location);
useEffect(() => {
const unlisten = history.listen((newLocation) => {
setLocation(newLocation);
});
return unlisten;
}, [history]);
const contextValue: RouterContextValue = {
history,
location,
};
return (
<RouterContext.Provider value={contextValue}>
{children}
</RouterContext.Provider>
);
};
Route Component:
interface RouteProps {
path: string;
component: React.ComponentType<any>;
exact?: boolean;
}
export const Route: React.FC<RouteProps> = ({
path,
component: Component,
exact = false
}) => {
const { location } = useRouter();
const matches = exact
? location.pathname === path
: location.pathname.startsWith(path);
if (!matches) {
return null;
}
return <Component />;
};
Switch Component for Exclusive Routing:
interface SwitchProps {
children: React.ReactNode;
}
export const Switch: React.FC<SwitchProps> = ({ children }) => {
const { location } = useRouter();
const childrenArray = React.Children.toArray(children);
for (const child of childrenArray) {
if (React.isValidElement(child)) {
const { path, exact = false } = child.props as RouteProps;
const matches = exact ? location.pathname === path : location.pathname.startsWith(path);
if (matches) {
return child;
}
}
}
return null;
};
Integration Points
Navigation Components:
interface LinkProps {
to: string;
replace?: boolean;
children: React.ReactNode;
className?: string;
}
export const Link: React.FC<LinkProps> = ({
to,
replace = false,
children,
className
}) => {
const { history } = useRouter();
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (replace) {
history.replace(to);
} else {
history.push(to);
}
};
return (
<a href={to} onClick={handleClick} className={className}>
{children}
</a>
);
};
export const NavLink: React.FC<LinkProps & { activeClassName?: string }> = ({
to,
replace = false,
children,
className,
activeClassName = 'active'
}) => {
const { location } = useRouter();
const history = useRouter().history;
const isActive = location.pathname === to;
const combinedClassName = isActive
? `${className || ''} ${activeClassName}`.trim()
: className;
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (replace) {
history.replace(to);
} else {
history.push(to);
}
};
return (
<a href={to} onClick={handleClick} className={combinedClassName}>
{children}
</a>
);
};
Navigation Hooks:
export const useNavigate = () => {
const { history } = useRouter();
return {
push: (path: string, state?: any) => history.push(path, state),
replace: (path: string, state?: any) => history.replace(path, state),
goBack: () => history.goBack(),
goForward: () => history.goForward(),
};
};
export const useParams = <T = Record<string, string>>(): T => {
const { location } = useRouter();
// Simple parameter extraction (can be enhanced for complex patterns)
const params: Record<string, string> = {};
const pathSegments = location.pathname.split('/').filter(Boolean);
// This is a simplified version - real implementation would use regex patterns
return params as T;
};
Advanced Techniques
Route Parameters and Wildcards
Enhanced Route Matching:
interface RouteParams {
[key: string]: string;
}
const pathToRegexp = (path: string): RegExp => {
// Convert path like "/users/:id" to regex
const paramNames: string[] = [];
const regexPath = path
.replace(/:([^/]+)/g, (_, paramName) => {
paramNames.push(paramName);
return '([^/]+)';
})
.replace(/\*/g, '(.*)');
return new RegExp(`^${regexPath}$`);
};
const matchPathWithParams = (pathname: string, route: RouteConfig): { matches: boolean; params: RouteParams } => {
const { path, exact = false } = route;
const regex = pathToRegexp(path);
const match = pathname.match(regex);
if (!match) {
return { matches: false, params: {} };
}
const paramNames = (path.match(/:([^/]+)/g) || []).map((param) => param.slice(1));
const params: RouteParams = {};
paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
return { matches: true, params };
};
Nested Routes
Nested Route Component:
interface NestedRouteProps {
path: string;
component: React.ComponentType<any>;
children?: React.ReactNode;
}
export const NestedRoute: React.FC<NestedRouteProps> = ({
path,
component: Component,
children
}) => {
const { location } = useRouter();
const { matches, params } = matchPathWithParams(location.pathname, { path });
if (!matches) {
return null;
}
return (
<Component params={params}>
{children}
</Component>
);
};
Route Guards and Authentication
Protected Route Component:
interface ProtectedRouteProps {
path: string;
component: React.ComponentType<any>;
isAuthenticated: boolean;
redirectTo?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
path,
component: Component,
isAuthenticated,
redirectTo = '/login'
}) => {
const { location } = useRouter();
const { history } = useRouter();
const { matches } = matchPathWithParams(location.pathname, { path });
useEffect(() => {
if (matches && !isAuthenticated) {
history.push(redirectTo);
}
}, [matches, isAuthenticated, history, redirectTo]);
if (!matches || !isAuthenticated) {
return null;
}
return <Component />;
};
Performance & Optimization
Route Memoization:
const MemoizedRoute = React.memo(Route, (prevProps, nextProps) => {
return (
prevProps.path === nextProps.path &&
prevProps.exact === nextProps.exact &&
prevProps.component === nextProps.component
);
});
Lazy Loading Routes:
const LazyRoute: React.FC<RouteProps> = ({ path, component: Component, exact }) => {
const [LazyComponent, setLazyComponent] = useState<React.ComponentType | null>(null);
useEffect(() => {
// Simulate dynamic import
import(`../components/${Component.name}`).then(module => {
setLazyComponent(() => module.default);
});
}, [Component]);
if (!LazyComponent) {
return <div>Loading...</div>;
}
return <Route path={path} component={LazyComponent} exact={exact} />;
};
Troubleshooting
Common Issues:
-
History not updating:
- Ensure proper event listeners for popstate
- Check that state updates trigger re-renders
- Verify context provider wraps all components
-
Route matching failures:
- Check path normalization (leading/trailing slashes)
- Verify exact vs non-exact matching logic
- Test parameter extraction patterns
-
Memory leaks:
- Clean up history listeners in useEffect
- Remove event listeners on component unmount
- Avoid creating functions in render that capture state
Common Questions
Q: Why build a custom router instead of using React Router? Building a custom router helps you understand routing fundamentals, allows for tailored solutions, and reduces bundle size for simple use cases. It’s also excellent learning for understanding how routing libraries work.
Q: How does this compare to React Router? Our implementation covers core functionality but lacks advanced features like route code splitting, route configs, and extensive browser support. React Router provides these features out of the box with better browser compatibility.
Q: Can I add server-side rendering? Yes, but you’d need to implement server-side history management and static route matching. The core concepts remain the same, but you’d need to handle initial route resolution on the server.
Tools & Resources
- Browser History API Documentation - Understanding native browser navigation
- URL Pattern API - Modern browser API for pattern matching
- React Context Documentation - State management patterns
- Path-to-RegExp Library - Advanced route pattern matching
Related Topics
Core React Patterns
- How to Implement React Hooks State Management
- Solving React Component Challenges: Practical Approach
- Quick Start Guide to React TypeScript
Performance & Optimization
- React Performance Optimization: Complete Guide
- TypeScript with React: Component Patterns and Type Safety
Error Handling & Testing
- JavaScript Error Handling: Try/Catch Patterns and Modern Error Management
- React Testing Mistakes to Avoid and How to Fix Them
JavaScript Fundamentals
- Understanding Asynchronous JavaScript
- JavaScript Modules: ES Modules vs CommonJS and Modern Bundling
Framework Comparisons
Need Help With Implementation?
Building a custom router is an excellent learning exercise, but production routing requires handling edge cases, browser compatibility, and performance optimization. Built By Dakic specializes in custom React development, including routing solutions tailored to specific application requirements. Get in touch for a free consultation and discover how we can help you build robust, scalable routing solutions.