Why Build Your Own Router in 2025?
As a Software Consultant, I am always excited to explore how emerging React features can help us build more intuitive, performant applications. Rather than competing with established routing libraries, this article serves as a practical guide to demonstrate some of React 18's most important features, such as useSyncExternalStore, useTransition, and the History API, through the process of creating a basic router. My intention is to share knowledge in a way that is approachable and useful, so you can gain confidence working with these hooks and patterns. By the end of this walkthrough, you will have a lightweight router that supports lazy loading and takes full advantage of React's concurrency capabilities.
Setting Up the Router State System
A simple way to hold routing state is to use useState. It works, but when you need to express more complex state transitions, it quickly becomes limiting. A better approach is to use useReducer, which allows you to describe state changes in clear, declarative actions.
// router-store-reducer.tsx
import {useCallback, useEffect, useMemo, useReducer} from "react";
// To enable type erasure, enum is intentionally not used
type ActionType = 'POPSTATE' | 'NAVIGATE';
type Action = { type: ActionType; path: string };
type RouterStoreStateType = {
path: string
};
function routerStoreReducer(state: RouterStoreStateType, action: Action): RouterStoreStateType {
switch (action.type) {
case 'POPSTATE':
case 'NAVIGATE':
return {path: action.path};
default:
return state;
}
}
export function ExampleApp() {
const [routerState, dispatch] = useReducer(routerStoreReducer, {path: window.location.pathname});
useEffect(() => {
const onPop = () => dispatch({type: 'POPSTATE', path: window.location.pathname});
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigate = useCallback((to: string) => {
window.history.pushState({}, '', to);
dispatch({type: 'NAVIGATE', path: to});
}, []);
const Component = useMemo(() => routerState.path === '/about' ? AboutPage : HomePage, [routerState.path]);
return (
<>
<nav>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
</nav>
<Component/>
</>
);
}
function HomePage() {
return <div>Home</div>;
}
function AboutPage() {
return <div>About</div>;
}
With a reducer in place, you have a clear foundation. However, if you want nested child components, such as individual page components, to respond to router state changes, you will end up passing props through multiple levels of the component tree (prop drilling).
...
export function ExampleAppProp() {
const [routerState, dispatch] = useReducer(routerStoreReducer, {path: window.location.pathname});
useEffect(() => {
const onPop = () => dispatch({type: 'POPSTATE', path: window.location.pathname});
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigate = useCallback((to: string) => {
window.history.pushState({}, '', to);
dispatch({type: 'NAVIGATE', path: to});
}, []);
const componentWithProp = useMemo(() => {
if (routerState.path === '/about') {
return <AboutPage routerState={routerState}/>;
}
if (routerState.path === '/') {
return <HomePage routerState={routerState}/>;
}
return null;
}, [routerState]);
return (
<>
<nav>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
</nav>
{componentWithProp}
</>
);
}
type HomePagePropsType = {
routerState: RouterStoreStateType
}
function HomePage({routerState}: HomePagePropsType) {
return <>
<div>Home</div>
<SomeInnerHomePageComponent routerState={routerState}/>
</>;
}
type SomeInnerHomePageComponentPropsType = {
routerState: RouterStoreStateType
}
function SomeInnerHomePageComponent({routerState}: SomeInnerHomePageComponentPropsType) {
return <div>Some inner component {routerState.path}</div>;
}
type AboutPagePropsType = {
routerState: RouterStoreStateType
}
function AboutPage({routerState}: AboutPagePropsType) {
return <div>About {routerState.path} </div>;
}
Prop drilling can become awkward as your component hierarchy grows. A common solution is to introduce a ContextProvider at the top level of your application:
const RouterContext = createContext<RouterContextType>({
path: window.location.pathname,
navigate: () => {
},
});
type RouterProviderPropsType = {
children: ReactNode
};
function RouterProvider({children}: RouterProviderPropsType) {
const [state, dispatch] = useReducer(routerStoreReducer, {path: window.location.pathname});
useEffect(() => {
const onPop = () => dispatch({type: 'POPSTATE', path: window.location.pathname});
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigate = useCallback((to: string) => {
window.history.pushState({}, '', to);
dispatch({type: 'NAVIGATE', path: to});
}, []);
return (
<RouterContext.Provider value={{path: state.path, navigate}}>
{children}
</RouterContext.Provider>
);
}
export function ExampleAppContext() {
return (
<RouterProvider>
<ExampleAppNav/>
<ExampleAppContent/>
</RouterProvider>
);
}
function ExampleAppNav() {
const {navigate} = useContext(RouterContext);
return (
<nav>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
</nav>
);
}
function ExampleAppContent() {
const {path} = useContext(RouterContext);
return path === '/about' ? <AboutPage/> : <HomePage/>;
}
function HomePage() {
return <>
<div>Home</div>
<SomeInnerHomePageComponent/>
</>;
}
function SomeInnerHomePageComponent() {
const {path} = useContext(RouterContext);
return <div>Some inner component {path}</div>;
}
function AboutPage() {
const {path} = useContext(RouterContext);
return <div>About {path} </div>;
}
While the ContextProvider removes the need for prop drilling, it can lead to a very large context and in a way, it could also be grouping all its child components into one large component. What if you want a navigation bar to listen for route changes without re-rendering every time the page content updates? Instead of relying on context, you can create a centralized store that works seamlessly with useSyncExternalStore. This lets any component subscribe directly to router updates without intermediary providers.
// router-store.ts
export type RouterStoreNotifyCallbackType = () => void;
export type RouterStoreDataType = {
path: string;
};
type RouterStoreDataUpdateType = {
path: string;
};
let cachedData: Readonly<RouterStoreDataType> = {
path: window.location.pathname,
};
type RouterStoreType = {
getSnapshot: () => Readonly<RouterStoreDataType>,
updateData: (data: RouterStoreDataUpdateType) => void,
subscribe: (callback: RouterStoreNotifyCallbackType) => void,
unsubscribe: (callback: RouterStoreNotifyCallbackType) => void,
};
let subscribers: Readonly<Set<RouterStoreNotifyCallbackType>> = new Set();
function emitChange() {
for (const subscriber of subscribers) {
subscriber();
}
}
export const routerStore: RouterStoreType = {
getSnapshot: () => cachedData,
updateData: (data) => {
if (data.path !== cachedData.path) {
cachedData = {
path: data.path,
};
window.history.pushState(null, "", data.path);
emitChange();
}
},
subscribe: (subscription) => {
const subscribersClone = new Set(subscribers);
subscribersClone.add(subscription);
subscribers = subscribersClone;
},
unsubscribe: (subscription) => {
const subscribersClone = new Set(subscribers);
subscribersClone.delete(subscription);
subscribers = subscribersClone;
},
};
This code establishes the basic store, but we still need a way for components to read from and write to that store.
Hooking into the Router State
To connect components to the routerStore, we will create a concise custom hook. This hook encapsulates subscription logic as well as read and update functions for router state. By using this hook, components can navigate or react to route changes without any prop drilling or context dependencies.
// router-hook.ts
import {routerStore, type RouterStoreDataType, type RouterStoreNotifyCallbackType} from "./router-store.ts";
import {useCallback, useSyncExternalStore} from "react";
type RouterHookType = {
data: Readonly<RouterStoreDataType>,
navigate: (path: string) => void,
};
function subscribe(callback: RouterStoreNotifyCallbackType) {
routerStore.subscribe(callback);
return () => {
routerStore.unsubscribe(callback);
}
}
export function useRouterHook(): RouterHookType {
const data = useSyncExternalStore(
subscribe,
routerStore.getSnapshot
);
const navigate = useCallback((to: string) => {
// Intentional setTimeout to make page navigation seem slower
setTimeout(() => {
routerStore.updateData({path: to});
}, 1000);
}, []);
return {data, navigate};
}
Navigation with a RouterLink
Now that we have a custom subscription hook, we can create a RouterLink component. This component simply calls the hook's navigation function when clicked. Centralizing navigation logic in one place makes it easy to maintain and reuse.
// router-link.tsx
import {useRouterHook} from "./router-hook.ts";
import {type AnchorHTMLAttributes, memo, type MouseEventHandler, useCallback} from "react";
interface RouterLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
to: string;
}
function RouterLink({to, children, ...props}: RouterLinkProps) {
const {navigate} = useRouterHook();
const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback((e) => {
e.preventDefault();
navigate(to);
}, [navigate, to]);
return (
<a href={to} onClick={handleClick} {...props}>
{children}
</a>
);
}
export default memo(RouterLink);
Creating the Router Provider Component
Eager Loading Version
Let's begin with an eager loading router component. It renders everything immediately based on the current path. This version establishes the basic routing mechanism and serves as a baseline for more advanced features.
// router-root-eager-load.tsx
import {memo, type ReactElement, useCallback, useLayoutEffect, useState} from "react";
import {useRouterHook} from "./router-hook.ts";
type RouterRootEagerLoadRouteType = {
[route: string]: ReactElement;
};
type RouterRootEagerLoadPropsType = {
routes: RouterRootEagerLoadRouteType,
};
function RouterRootEagerLoad({routes}: RouterRootEagerLoadPropsType) {
const {data, navigate} = useRouterHook();
const [readyElement, setReadyElement] = useState<ReactElement | null>(null);
const popStateListener = useCallback(() => {
if (window.location.pathname != data.path) {
navigate(window.location.pathname);
}
}, [data.path, navigate]);
useLayoutEffect(() => {
window.addEventListener('popstate', popStateListener);
return () => {
window.removeEventListener('popstate', popStateListener);
}
}, [popStateListener]);
useLayoutEffect(() => {
setReadyElement(routes[data.path]);
}, [data.path, routes]);
return (
<div>
{readyElement}
</div>
);
}
export default memo(RouterRootEagerLoad);
// example-app-eager-load.tsx
import {useRouterHook} from "./router-hook.ts";
import App from "./App.tsx";
import Register from "./register.tsx";
import Login from "./login.tsx";
import Todo from "./todo.tsx";
import RouterRootEagerLoad from "./router-root-eager-load.tsx";
const routes = {
'/': <App/>,
'/register': <Register/>,
'/login': <Login/>,
'/todos': <Todo/>,
};
export function ExampleAppEagerLoad() {
return <>
<ExampleAppNav/>
<RouterRootEagerLoad routes={routes}/>
</>;
}
function ExampleAppNav() {
const {navigate} = useRouterHook();
return (
<nav>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/register')}>Register</button>
<button onClick={() => navigate('/login')}>Login</button>
<button onClick={() => navigate('/todos')}>Todos</button>
</nav>
);
}
Here's how that looks like
Adding Lazy Loading with Suspense
To improve user experience, we can add lazy loading using React's Suspense. In this example, we will simulate a 5-second delay to illustrate how Suspense displays a fallback while components are loading.
// router-root-basic.tsx
import {
type ComponentType,
type LazyExoticComponent,
memo,
type ReactElement,
Suspense,
useCallback,
useLayoutEffect,
useState
} from "react";
import {useRouterHook} from "./router-hook.ts";
type RouterRootRouteType = {
[route: string]: LazyExoticComponent<ComponentType>;
};
type RouterRootPropsType = {
routes: RouterRootRouteType,
};
function RouterRootBasic({routes}: RouterRootPropsType) {
const {data, navigate} = useRouterHook();
const [readyElement, setReadyElement] = useState<ReactElement | null>(null);
const makeElement = useCallback((path: string) => {
const LazyElement = routes[path];
if (LazyElement != null) {
return <LazyElement/>
}
return null;
}, [routes]);
const popStateListener = useCallback(() => {
if (window.location.pathname != data.path) {
navigate(window.location.pathname);
}
}, [data.path, navigate]);
useLayoutEffect(() => {
window.addEventListener('popstate', popStateListener);
return () => {
window.removeEventListener('popstate', popStateListener);
}
}, [popStateListener]);
useLayoutEffect(() => {
setReadyElement(makeElement(data.path));
}, [data.path, makeElement]);
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
{readyElement}
</Suspense>
</div>
);
}
export default memo(RouterRootBasic);
// example-app-basic.tsx
import {lazy, memo} from "react";
import {useRouterHook} from "./router-hook.ts";
import RouterRootBasic from "./router-root-basic.tsx";
const routes = {
'/': lazy(() => delayForDemo(import('./App.tsx'))),
'/register': lazy(() => delayForDemo(import('./register.tsx'))),
'/login': lazy(() => delayForDemo(import('./login.tsx'))),
'/todos': lazy(() => delayForDemo(import('./todo.tsx'))),
};
function delayForDemo(promise: any) {
return new Promise(resolve => {
setTimeout(resolve, 5000);
}).then(() => promise);
}
export function ExampleAppBasic() {
return <>
<ExampleAppNavMemoized/>
<RouterRootBasic routes={routes}/>
</>;
}
const ExampleAppNavMemoized = memo(ExampleAppNav);
function ExampleAppNav() {
return (
<nav>
<ExampleHomeButtonMemoized/>
<ExampleRegisterButtonMemoized/>
<ExampleLoginButtonMemoized/>
<ExampleTodosButtonMemoized/>
</nav>
);
}
// The code below will all be fixed with React Compiler and we can just use a loop instead
const ExampleHomeButtonMemoized = memo(ExampleHomeButton);
function ExampleHomeButton() {
const {navigate} = useRouterHook();
return <button onClick={() => navigate('/')}>Home</button>;
}
const ExampleRegisterButtonMemoized = memo(ExampleRegisterButton);
function ExampleRegisterButton() {
const {navigate} = useRouterHook();
return <button onClick={() => navigate('/register')}>Register</button>;
}
const ExampleLoginButtonMemoized = memo(ExampleLoginButton);
function ExampleLoginButton() {
const {navigate} = useRouterHook();
return <button onClick={() => navigate('/login')}>Login</button>;
}
const ExampleTodosButtonMemoized = memo(ExampleTodosButton);
function ExampleTodosButton() {
const {navigate} = useRouterHook();
return <button onClick={() => navigate('/todos')}>Todos</button>;
}
Here's how that looks like
Enabling Concurrency with useTransition
React 18 introduces concurrency features that help keep the UI responsive during expensive updates. Instead of blocking the user interface with a Suspense fallback, we can use useTransition so that navigation does not interrupt ongoing interactions:
// router-root.tsx
import {
type ComponentType,
type LazyExoticComponent,
memo,
type ReactElement,
useCallback,
useLayoutEffect,
useState,
useTransition
} from "react";
import {useRouterHook} from "./router-hook.ts";
type RouterRootRouteType = {
[route: string]: LazyExoticComponent<ComponentType>;
};
type RouterRootPropsType = {
routes: RouterRootRouteType,
};
function RouterRoot({routes}: RouterRootPropsType) {
const {data, navigate} = useRouterHook();
const [isPending, startTransition] = useTransition();
const [readyElement, setReadyElement] = useState<ReactElement | null>(null);
const makeElement = useCallback((path: string) => {
const LazyElement = routes[path];
if (LazyElement != null) {
return <LazyElement/>
}
return null;
}, [routes]);
const popStateListener = useCallback(() => {
if (window.location.pathname != data.path) {
navigate(window.location.pathname);
}
}, [data.path, navigate]);
useLayoutEffect(() => {
window.addEventListener('popstate', popStateListener);
return () => {
window.removeEventListener('popstate', popStateListener);
}
}, [popStateListener]);
useLayoutEffect(() => {
startTransition(() => {
setReadyElement(makeElement(data.path));
});
}, [data.path, makeElement]);
return (
<div>
{isPending ? <div>Loading...</div> : null}
{readyElement}
</div>
);
}
export default memo(RouterRoot);
...
export function ExampleAppFinal() {
return <>
<ExampleAppNavMemoized/>
<RouterRoot routes={routes}/>
</>;
}
...
Here's how that looks like
When you compare these approaches, you will see that the UI remains interactive while the new route's components load. This pattern aligns with React's vision for concurrent rendering, leading to a smoother user experience.
Limitations and What's Next
No Server-Side Rendering Support
Currently, this router is designed for client-side rendering only. If you need server-side rendering, you would have to extend the implementation substantially.
URL-Coupled and Limited Complex Navigation
This simple router relies on the browser's URL and does not support multiple router instances on the same page. It also lacks advanced nested routing patterns or path-matching capabilities.
Testing Is Not Included Here
Although tests are not part of this walkthrough, the modular structure makes it straightforward to add unit and integration tests. In a follow-up article, I plan to demonstrate how to write tests for each module of this router.
Closing Thoughts
Building a minimal router helps clarify how routing, suspense, and concurrency features work under the hood. In this article, we have:
Explored the limitations of holding router state with useState and seen how useReducer provides a clearer way to describe state transitions.
Discussed the challenges of prop drilling and how a ContextProvider can help, but also why a centralized store can be a more flexible alternative.
Created a routerStore that leverages useSyncExternalStore, enabling components to subscribe directly to route changes without prop drilling or context nesting.
Written a concise custom hook to read from and update the router store, making navigation logic reusable across components.
Built a RouterLink component that centralizes navigation behavior and simplifies link handling in an application.
Implemented an Eager Loading router to establish the basic routing mechanism, then introduced Lazy Loading with React’s Suspense to improve user experience during component loading.
Replaced Suspense fallbacks with the useTransition hook to keep the UI responsive during route transitions, demonstrating React 18’s concurrency capabilities.
Acknowledged areas for improvement, such as the lack of server-side rendering, advanced nested routing patterns, and built-in tests, and outlined how the modular design makes future enhancements straightforward.
My hope is that this walkthrough demystifies some of the React 18 advancements and encourages you to experiment with these hooks in your own projects. If you have questions or suggestions, please reach out! Collaboration is how our community grows. Happy coding!
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more