DEV Community

Cover image for Build Your Own Lightweight React Router Using React 18 Features
Huiren Woo for Zenika

Posted on • Originally published at blog.zenika.com

Build Your Own Lightweight React Router Using React 18 Features

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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;
    },
};
Enter fullscreen mode Exit fullscreen mode

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};
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

Here's how that looks like

Image description

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);
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode

Here's how that looks like

Image description

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);
Enter fullscreen mode Exit fullscreen mode
...
export function ExampleAppFinal() {
    return <>
        <ExampleAppNavMemoized/>
        <RouterRoot routes={routes}/>
    </>;
}
...
Enter fullscreen mode Exit fullscreen mode

Here's how that looks like

Image description

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