Did you know you can write your own typesafe React router in 500 lines?

Did you know you can write your own typesafe React router in 500 lines?

Published at 6 March 2024

When I was learning React around 6 years ago, react-router was one of the first third-party libraries I picked up. I mean, it makes sense: routing is one of the key aspects of modern Single Page Application. I took it for granted. For me, react-router was a magic box, I had no idea how it works internally. So when after some time I naturally figured out how routing works in general, it was a bit sad. No more magic, huh :(

But I'm glad I left that concept of "magic black boxes" behind. I think it's a really harmful idea. Undestanding that every piece of tech ever has been built by engineers, just like you and me, inspires me a lot, and I hope it does the same for you.

So join me in this post where we'll build our own typesafe router from scratch to open that black box and understand its inner workings. This article assumes you already know React and comfortable with TypeScript.

Table of contents

Features and limitations

Let me outline which features of our router will be covered in this article.

Our killer feature will be typesafety. This means you'll need to define your routes in advance and TypeScript will check that you don't pass some nonsense instead of URL or try to get parameters that are missing in current route. This will require some type gymnastics, but don't worry, I'll walk you through.

Besides that, our router will support everything you'd expect from average router: navigation to URLs, route matching, route parameters parsing, back/forward navigation, and navigation blocking.

Now, about limitations. Our router will work only in browser (sorry React Native!) and it won't support SRR. SSR support should be relatively easy, but this post is already huge so I won't cover it.

Terminology

Now that we have a vision of what we'd like to make, we need to talk about stucture and terminology. There will be quite a lot of similar concepts, so defining them in advance is crucial.

There will be two kinds of routes in our library: raw routes and parsed routes. Raw route is just a string that looks like /user/:id/info or /login, it's a template for URL. Raw route can contain parameters, which are sections that start with a colon, like :id. This format is easy to use for developers, but not so much for a program, we'll transform those routes into a more machine-friendly format and call it parsed route.

But users don't open routes, they open URLs. And URL is an identifier for resource (page inside the app in our case), it might look like http://example.app/user/42/info?anonymous=1#bio. Our router mostly cares about the second part of the URL (/user/42/info?anonymous=1#bio), that we'll call path. Path consists of pathname (/user/42/info), search parameters (?anonymous=1) and hash (#bio). The object that stores those components as separate fields will be called location.

High-level overview of API

When building React libraries, I like to go in three steps. First of all, imperative API. Its functions can be called from any place and they're usually not tied to React at all. In case of a router, that will be functions like navigate, getLocation or subscribe. Then, based on those functions, hooks like useLocation or useCurrentRoute are made. And then those functions and hooks are used as a base for building components. This works exceptionally well, in my experience, and allows for making an easily extendable and versatile library.

Router's API starts with defineRoutes function. The user is supposed to pass a map of all raw routes to the function, which parses the routes and returns a mapping with the same shape. All developer-facing APIs, like URL generation or route matching, will accept parsed routes and not raw.

const routes = defineRoutes({
    login: '/login',
    user: {
        me: '/user/me',
        byId: '/user/:id/info',
    }
});

Next step is to pass parsed routes to createRouter function. This is the meat of our router. This function will create all of the functions, hooks and components. This might look unusual, but such structure alows us to tailor the types of accepted arguments and props to a specific set of routes defined in routes, ensuring typesafety (and improving DX).

const { 
    Link, 
    Route, 
    useCurrentRoute, 
    navigate,
    /* etc... */ 
} = createRouter(routes);

createRouter will return functions which can be used anywhere in your app (imperative API), hooks which allow your components to react to location changes and three components: Link, Route and NotFound. This will be enough to cover the majority of use-cases and you can build your own components based on those APIs.

Type-level programming for fun and profit

We start by tackling the typesafety part of our pitch. As I mentioned before, with typesafe router TypeScript will warn you in advance about a situation like this:

<Link href="/logim" />

Or like this:

const { userld } = useRoute(routes.user.byId);

And if you can't see right away what's wrong with these, you definitely need a typesafe router :)

The type system in TypeScript is very powerful. I mean, you can make a chess engine, an adventure game or even an SQL database using type-level programming.

You're already familiar with 'value-level programming' where you manipulate values, e.g. concatenating two strings:

function concat(a, b) {
    return a + b;
}
concat('Hello, ', 'World!'); // 'Hello, World!'

But you can do it with types too!

type Concat<A extends string, B extends string> = `${A}${B}`;
 
type X = Concat<'Hello, ', 'World!'>;
//   ^? type X = "Hello, World!"

Yes, it's not as powerful as your ordinary functions and looks different. But it allows you to do some pretty cool and useful stuff. We'll use type-level programming to extract parameters from raw routes and build new types which can check that developer doesn't try to pass incorrect values to Link or to the function that's constructing the URL.

Type-level programming with TS might quickly become an unreadable mess. Fortunately for us, there are already multiple projects that hide all this complexity away and allow us to write clean code like this:

export type RouteParam<
    Route extends RawRoute,
> = Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Filter<Strings.StartsWith<":">>,
        Tuples.Map<Strings.TrimLeft<":">>,
        Tuples.ToUnion
    ]
>;

Pretty neat, huh? That, by the way, is all code you need to parse parameters from raw route. In this project we'll use hotscript library - it will help us reduce complexity and the amount of type-level code. But it's not required: if you feel adventurous, you can try implementing all these types yourself. You can find some inspiration in Chicane router, which implements similar features without using third-party type libraries.

If you're going to follow along, I recommend you create a new React project using your favorite starter (I use Vite) and start coding there. This way you'll be able to test your router right away. Please note that frameworks like Next.js provide their own routing which can interfere with this project, and use 'vanilla' React instead. If you have any difficulties, you can find complete code here.

Start by installing third-party packages: hotscript for type-level utilities and regexparam for parsing parameters from URL/raw route.

npm install hotscript regexparam

The first building brick of our types is raw route. Raw route should start with /, how would you code that in TS? Like this:

export type RawRoute = `/${string}`;

Easy, right? But defineRoutes doesn't accept a single raw route, it accepts mapping, possibly nested, so let's code that. You might be tempted to write something like this:

export type RawRoutesMap = {
    [key: string]: RawRoute | RawRoutesMap
};

This will work. However, this type can be infinitely deep and TS will have a hard time calculating it in some instances. To make TS's life easier we'll limit the level of allowed nesting. 20 nesting levels should be plenty for all apps and TS can handle that easily. But how do you limit the depth of recursive types? I learned this trick from this SO answer, here is a version modified for our requirements:

export type RecursiveMap<T, MaxDepth extends number> = {
    [key: string]: RecursiveMap_<T, MaxDepth, []>;
};
 
type RecursiveMap_<T, MaxDepth extends number, Stack extends unknown[]> = 
    MaxDepth extends Stack["length"]
        ? T
        : T | { [key: string]: RecursiveMap_<T, MaxDepth, [1, ...Stack]> };

This is our first complex type, so let me explain it. We have two types here: RecursiveMap works as entry point and calls RecursiveMap_ passing it an additional tuple parameter. This tuple is used to track the depth of the mapping, with every call we add one element to this array. And we continue to call it until the length of this tuple is equal to MaxDepth. In TS, when extends is used with specific values, also called literals (e.g. specifically 42, not number), it means 'equal'. And since both MaxDepth and Stack["length"] are specific numbers, this code can be read as MaxDepth === Stack["length"]. You will see this construction being used a lot.

Why use tuple instead of just adding numbers? Well, it's not that easy to add two numbers in TypeScript! There is a whole library for that, and Hotscript can add numbers too, but it requires a lot of code (even if you don't see it), which can slow your TS server and code editor, if used excessively. So my rule of thumb is to avoid complex types as much as reasonably possible.

With this utility type, we can define our mapping as simple as:

export type RawRoutesMap = RecursiveMap<RawRoute, 20>;

That's all for raw route types. Next in the queue is parsed route. Parsed route is just a JavaScript object with a few additional fields and one function, here's how it looks:

export type ParsedRoute<R extends RawRoute> = {
    keys: RouteParam<R>[];
    build(...params: PathConstructorParams<R>): Path<R>;
    raw: R;
    ambiguousness: number,
    pattern: RegExp;
};

Let's start unpacking this from the keys field. It's simply an array of parameters which are required for this route. Here is how its done:

import { Pipe, Strings, Tuples } from "hotscript";
 
export type RouteParam<
    Route extends RawRoute,
> = Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Filter<Strings.StartsWith<":">>,
        Tuples.Map<Strings.TrimLeft<":">>,
        Tuples.ToUnion
    ]
>;

In Hotscript, there are two ways to call a function: Call or Pipe. Call is useful when you need to call a single function, but in our case we have 4 of them! Pipe accepts input and, well, pipes it into the first function of a provided tuple. Returned value is passed as input into the second function and so on. In our case, if we had, for example, raw route /user/:userId/posts/:postId, it will be transformed like this:

export type Beep = Pipe<
    "/user/:userId/posts/:postId",
    [
        Strings.Split<"/">, // ["user", ":userId", "posts", ":postId"]
        Tuples.Filter<Strings.StartsWith<":">>, // [":userId", ":postId"]
        Tuples.Map<Strings.TrimLeft<":">>, // ["userId", "postId"]
        Tuples.ToUnion // "userId" | "postId"
    ]
>;

See? This is the magic of type-level programming! Now let's tackle that build function. It accepts route parameters (like userId and postId) and optional search params/hash, and combines them into a path. Take a look at a PathConstructorParams implementation:

// Allows us to also accept number and 
// any other type which can be converted into string
export type StringLike = { toString: () => string };
 
export type SearchAndHashPathConstructorParams = {
    hash?: string,
    search?: string | {
        [key: string]: string,
    }
};
 
export type RouteParamsMap<
    Route extends RawRoute,
    Val extends string | StringLike = string,
> = { [key in RouteParam<Route>]: Val };
 
export type PathConstructorParams<R extends RawRoute> = 
    | [RouteParamsMap<R, StringLike>] 
    | [RouteParamsMap<R, StringLike>, SearchAndHashPathConstructorParams];

Function parameters are defined as an array (which is later ...spread in the definition of the build function), where the first element is RouteParamsMap and the second is optional SearchAndHashPathConstructorParams. What about returning the value of build? We already established its path, but how do you describe it with TypeScript? Well, this one is quite similar to RouteParam, but requires a bit more of type gymnastics!

import { Fn } from "hotscript";
 
interface ReplaceParam extends Fn {
    return: this["arg0"] extends `:${string}` ? string : this["arg0"];
}
 
// Leading slashes will be removed by Split<"/">, so we need to 
// add them back after our manipulations
type Pathname<
    Route extends RawRoute,
> = `/${Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Map<ReplaceParam>,
        Tuples.Join<"/">
    ]
>}${Route extends `${string}/` ? '/' : ''}`;
 
export type Path<
    Route extends RawRoute,
> = Pathname<Route> | `${Pathname<Route>}?${string}` | `${Pathname<Route>}#${string}`;

What we do here is split our route into segments, map over each segment and call our custom function ReplaceParam on each. It checks if the current segment is a parameter and replaces it with string or returns the segment as-is. ReplaceParam 'function' might look a bit weird, but that's how you define custom functions with Hotscript. We explicitly state that path consists either from just path, path followed by question mark (this covers URLs with search params and hash) or hash symbol (this covers URLs without search params, but with hash).

We'll also need a type to describe matched route, i.e. parsed route with parameters captured from URL:

// Interface (and not type) because we need to use `this`
export interface RouteWithParams<R extends RawRoute> {
    route: ParsedRoute<R>,
    params: RouteParamsMap<R>,
    // TS can't properly infer type of route object with simple 
    // check like currentRoute.route === routes.user.byId, so we
    // need our custom type guard
    matches: <T extends RawRoute>(route: ParsedRoute<T>) => this is RouteWithParams<T>,
}

Last type is ParsedRoutesMap, it's similar to RawRoutesMap, but for, well, parsed routes.

// This accepts RawRoutesMap and transforms it into 
// mapping of parsed routes of same shape
export type ParsedRoutesMap<RM extends RawRoutesMap> = {
    [Key in keyof RM]: RM[Key] extends RawRoute
        ? ParsedRoute<RM[Key]>
        : RM[Key] extends RawRoutesMap
            ? ParsedRoutesMap<RM[Key]>
            : never;
};

And on that note, we finish with types. There will be a few more, but they are simpler and we'll cover them as we go with the implementation. If type-level programming is something you'd like to try more of, you can check out Type-level Typescript to learn more and try to solve type-challenges (they have a good list of resources too).

Route parser

Finally we're back to regular value-level coding. Let's get the ball rolling by implementing defineRoutes.

export const typedKeys = <const T extends {}> (obj: T) => {
    return Object.keys(obj) as Array<keyof T>;
};
 
export const defineRoutes = <const T extends RawRoutesMap>(routesMap: T): ParsedRoutesMap<T> => {
    const entries = typedKeys(routesMap).map((key) => {
        const entry = routesMap[key];
        if (typeof entry === 'string') {
            return [key, parseRoute(entry)] as const;
        } else {
            // Nested map
            return [key, defineRoutes(entry)] as const;
        }
    });
 
    return Object.fromEntries(entries);
};

Nothing complex here, let's dive deeper into parseRoute function.

import { parse, inject, type RouteParams as RegexRouteParams } from "regexparam";
 
export class InvalidRoute extends Error { };
 
export class InvalidRouteParams extends Error { };
 
const parseRoute = <const R extends RawRoute>(route: R): ParsedRoute<R> => {
    if (!route.startsWith('/')) {
        throw new InvalidRoute('route should start with slash (/)')
    }
    
    const { keys, pattern } = parse(route);
    const hasRequiredParams = keys.length > 0;
    const parsedRoute: ParsedRoute<R> = {
        build(...args) {
            const params = (
                hasRequiredParams ? args[0] : undefined
            ) as RouteParamsMap<R, StringLike> | undefined;
 
            const searchAndHash = (
                hasRequiredParams ? args[1] : args[0]
            ) as SearchAndHashPathConstructorParams | undefined;
 
            if (hasRequiredParams) {
                if (!params) {
                    throw new InvalidRouteParams(
                        `Parameters for route ${route} weren't provided`
                    );
                }
                const missingKeys = keys.filter(k => !(k in params));
                if (missingKeys.length) {
                    throw new InvalidRouteParams(
                        `Missing parameters for route ${route}: ${missingKeys.join(', ')}`
                    );
                }
            } else if (args.length > 1) {
                throw new InvalidRouteParams(
                    `Route ${route} doesn't accept any parameters, received ${args[0]}`
                );
            }
    
            let path = hasRequiredParams ? inject(route, params as RegexRouteParams<R>) : route;
            if (searchAndHash && searchAndHash.search) {
                if (typeof searchAndHash.search === 'string') {
                    path += searchAndHash.search.startsWith('?') 
                        ? searchAndHash.search 
                        : '?' + searchAndHash.search;
                } else {
                    path += '?' + new URLSearchParams(searchAndHash.search).toString();
                }
            }
            if (searchAndHash && searchAndHash.hash) {
                path += searchAndHash.hash.startsWith('#') 
                    ? searchAndHash.hash 
                    : '#' + searchAndHash.hash;
            }
    
            return path as Path<R>;
        },
        raw: route,
        keys: keys as RouteParam<R>[] || [],
        ambiguousness: keys.length,
        pattern: pattern,
    };
 
    return parsedRoute;
};

parseRoute is also very simple, albeit noticeably longer. To parse route and extract parameters we use regexparam library. It allows us to get an array of parameters required for route, and generates a regular expression which we'll later use to match the URL with route. We store this info along with original raw route used to construct this object and ambiguousness level (which is just a number of parameters in route).

History wrapper

Every router has to store its state somewhere. In case of apps in browser, that really boils down to 4 options: in-memory (either in a state variable inside the root component or in a variable outside of the components tree), History API or hash part of the URL. In-memory routing might be your choice if you don't want to show the user you have routes at all, for example if you're coding a game in React. Storing the route in hash can be handy when your React app in only one page in a bigger application and you can't just change the URL however you want.

But for most cases using History API will be the best option. It's compatible with SSR (other options aren't), follows behavior patterns user is accustomed to and just looks cleanest. In this project we'll be using it too. It has one notable flaw though: it's mostly unusable without additional wrappers.

With History API you can subscribe to popstate event and the browser will let you know when the URL changes. But only if the change is initiated by user by, for example, clicking on the back button. If URL change is initiated from code, you need to keep track of it yourself. Most of routers I studied use their own wrapper: react-router and chicane use history NPM package, TanStack router has its own implementation and wouter doesn't have a full-fledger wrapper, but still has to monkey-patch history.

So let's implement our own wrapper.

export type HistoryLocation = Pick<Location, 
    | 'origin' 
    | 'href' 
    | 'hash' 
    | 'search' 
    | 'pathname'
>;
 
export type NavigationBlocker = (isSoftNavigation: boolean) => boolean;
 
export const createHistory = () => {
    const winHistory = window.history;
    const winLocation = window.location;
 
    const getLocation = (): HistoryLocation => {
        return { 
            origin: winLocation.origin,
            href: winLocation.href,
            pathname: winLocation.pathname,
            search: winLocation.search,
            hash: winLocation.hash,
         };
    };
 
    /* Some magic code */
 
    return /* something... */;
};

There are two types we'll use, HistoryLocation and NavigationBlocker. First is a bit limited version of the built-in Location type (that's the type of window.location), and second will be covered once we get to navigation blocking. All further code from this chapter will go inside createHistory function.

Let's start with implementing a subscription to history changes. We'll use React-style functions for subscribing in this project: you call subscribe passing a callback and it returns another function which you need to call when you want to unsubscribe.

const subscribers: Set<VoidFunction> = new Set();
 
const onChange = () => {
    subscribers.forEach(fn => {
        try {
            fn();
        } catch (err) {
            console.error('Error while handling location update', err);
        }
    })
};
 
const subscribe = (listener: VoidFunction) => {
    subscribers.add(listener);
    return () => {
        subscribers.delete(listener);
    };
};

Next step is to react to location changes, including changes made programmatically. How would you do it? With monkey-patching of course. That might look a bit dirty (and it really is), but we don't have better options, unfortunately.

const origPushState = winHistory.pushState.bind(winHistory);
const origReplaceState = winHistory.replaceState.bind(winHistory);
 
winHistory.pushState = (data, unused, url) => {
    // tryNavigate will be covered later
    tryNavigate(() => {
        origPushState(data, unused, url);
        onChange();
    });
};
winHistory.replaceState = (data, unused, url) => {
    tryNavigate(() => {
        origReplaceState(data, unused, url);
        onChange();
    });
};
 
// This event is emmited when user initiates navigation 
// or when calling history.go, history.back and history.forward
window.addEventListener('popstate', onChange);

And the last major missing piece in our history implementation is navigation blocking: a feature which allows you to intercept the navigation request and conditionally cancel it. A canon example of navigation blocking would be preventing the user from losing their progress in a form.

let blockers: NavigationBlocker[] = [];
 
const beforeUnloadHandler = (event: Event) => {
    const blocked = blockers.some(blocker => blocker(false));
    if (blocked) {
        event.preventDefault();
        // @ts-ignore For older browsers
        event.returnValue = '';
        return '';
    }
};
 
const tryNavigate = (cb: VoidFunction) => {
    const blocked = blockers.some(blocker => blocker(true));
    if (blocked) return;
    cb();
};
 
const addBlocker = (blocker: NavigationBlocker) => {
    blockers.push(blocker);
    if (blockers.length === 1) {
        addEventListener('beforeunload', beforeUnloadHandler, { capture: true });
    }
 
    return () => {
        blockers = blockers.filter(b => b !== blocker);
        if (blockers.length === 0) {
            removeEventListener('beforeunload', beforeUnloadHandler, { capture: true });
        }
    }
};

In our implementation, a blocker is a function that returns a boolean indicating whether we need to block this navigation. In regards to navigation blocking, there are two types of navigation and we'll need to handle them differently.

On one hand, there is soft navigation - when the user navigates from one page in our app to another page in our app. We fully control it and thus can block it, display any custom UI (to confirm user's intent) or perform actions after blocking the navigation.

On the other hand, there is hard navigation - when the user navigates to another site or closes the tab altogether. Browser can't allow JavaScript to decide if this navigation should be performed, as it will be a security concern. But the browser allows JavaScript to indicate if we want to show an extra confirmation dialog to user.

When blocking soft navigation, you might want to display additional UI (e.g. custom confirmation dialog), but in case of hard navigation, it doesn't really make sense as the user will only see it if they decide to remain on the page and, at that point, it's useless and confusing. When our history calls the navigation blocker function, it will provide a boolean, indicating if we're performing soft navigation.

And with all that, we just need to return our history object:

return {
    subscribe,
    getLocation,
    push: winHistory.pushState,
    replace: winHistory.replaceState,
    go: (distance: number) => tryNavigate(() => winHistory.go.call(winHistory, distance)),
    back: () => tryNavigate(() => winHistory.back.call(winHistory)),
    forward: () => tryNavigate(() => winHistory.forward.call(winHistory)),
    addBlocker,
};

Step 1: Imperative API

We're finally here. Imperative API will be the base for all further hooks and components and will allow the developer to build custom hooks to cover their needs. First of all, we need to transform our routes map into a flat array. This way, it will be a lot easier to loop over all routes, which will come in handy when we start working on the route matching part. We need both type utility (which will transform ParsedRoutesMap into union of ParsedRoute) and function (which will transform routesMap into an array of parsed routes). Let's start with type:

export type Values<T extends {}> = T[keyof T];
 
type FlattenRouteMap<T> = T extends ParsedRoute<any> | RawRoute
    ? T
    : T extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap
        ? AllRoutesFromMap<T>
        : never;
 
export type AllRoutesFromMap<
    RM extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap
> = FlattenRouteMap<Values<RM>>;

It might look unnecessary to split this into two types, but there is one very important reason for it: if you implement it as a single type that calls itself, TS will complain that the type is excessively deep and possibly infinite. So we work around this by splitting it into two types that call eachother.

For a value-level function we'll also need a type guard to check if the passed value is a parsed route.

export const isParsedRoute = <T extends `/${string}` = `/${string}`>(
    route: any
): route is ParsedRoute<T> => {
    return !!route 
        && typeof route === 'object' 
        && typeof route.raw === 'string' 
        && typeof route.build === 'function';
}
 
export const getAllRoutes = <T extends RawRoutesMap>(
    routesMap: ParsedRoutesMap<T>
): ParsedRoute<AllRoutesFromMap<T>>[] => {
    type PossibleRawRoute = AllRoutesFromMap<T>;
    return typedKeys(routesMap).flatMap((k) => {
        const val = routesMap[k];
        if (isParsedRoute<PossibleRawRoute>(val)) {
            return [val] as const;
        }
        // At this point we know that val isn't ParsedRoute, so it has to be map of routes
        // but TS can't infer that, so we help him a little by casting val to correct type 
        return getAllRoutes(val as ParsedRoutesMap<T>);
    });
};

Now, let's start implementing our router. Like with history, in this and the next two chapters all code will go into the createRouter function, unless stated otherwise.

import { 
    useSyncExternalStore, ComponentType, useMemo, MouseEventHandler, ComponentProps, useEffect 
} from 'react';
 
export const createRouter = <T extends RawRoutesMap>(routesMap: ParsedRoutesMap<T>) => {
    // Type for any possible route from passed routesMap
    type RouteType = AllRoutesFromMap<T>;
    // Similar to above, but for matched routes, i.e. includes URL parameters
    type BindedRouteWithParams = RouteWithParams<RouteType>;
    // Some of our functions will accept route filter, 
    // which can be single parsed route, array or object
    type RouteFilter<T extends RouteType> =
        | ParsedRoute<T>
        | ParsedRoute<T>[]
        | Record<string, ParsedRoute<T>>;
    
    const history = createHistory();
    const routes = getAllRoutes(routesMap);
 
};

First of all, let's teach our router to match current location to one of the known routes. A couple of utilities can go into global scope or separate file, not inside the createRouter function:

export const filterOutFalsy = <T>(obj: T[]): Exclude<T, undefined>[] => {
    return obj.filter(Boolean) as Exclude<T, undefined>[];
};
 
export class RouteMatchingConflict extends Error { };
 
// This will be used later
export class RouteMismatch extends Error { };

And this code goes into the createRouter function.

const extractRouteParams = <T extends RawRoute>(
    pathname: string, 
    parsedRoute: ParsedRoute<T>
) => {
    const match = parsedRoute.pattern.exec(pathname);
    if (!match) return undefined;
 
    // Extract all route parameters from match array 
    // and construct object from them
    return Object.fromEntries(parsedRoute.keys.map((key, index) => {
        return [key, match[index + 1]];
    })) as RouteParamsMap<T>;
};
 
const findMatchingRoute = (
    location: HistoryLocation
): BindedRouteWithParams | undefined => {
    const matchingRoutes = filterOutFalsy(routes.map(route => {
        const params = extractRouteParams<RawRoute>(
            location.pathname, 
            route
        );
        if (!params) return undefined;
        return {
            route,
            params,
            matches<T extends RawRoute>(r: ParsedRoute<T>) {
                return route === r;
            },
        };
    }));
 
    if (matchingRoutes.length === 0) return undefined;
    if (matchingRoutes.length === 1) return matchingRoutes[0];
 
    // At this point we have multiple matching routes :/ 
    // Gotta decide which one we prefer
    let lowestAmbiguousnessLevel = Infinity;
    let lowestAmbiguousnessMatches: BindedRouteWithParams[] = [];
    matchingRoutes.forEach((match) => {
        if (match.route.ambiguousness === lowestAmbiguousnessLevel) {
            lowestAmbiguousnessMatches.push(match);
        } else if (match.route.ambiguousness < lowestAmbiguousnessLevel) {
            lowestAmbiguousnessLevel = match.route.ambiguousness;
            lowestAmbiguousnessMatches = [match];
        }
    });
    if (lowestAmbiguousnessMatches.length !== 1) {
        throw new RouteMatchingConflict(
            `Multiple routes with same ambiguousness level matched pathname ${location.pathname}: ${lowestAmbiguousnessMatches.map(m => m.route.raw).join(', ')}`
        );
    }
 
    return lowestAmbiguousnessMatches[0];
};
 
let currentRoute = findMatchingRoute(history.getLocation());
 
// This function will be later returned from createRouter function
const getCurrentRoute = () => currentRoute;

Here we go over all known routes and try to match each one to the current location. If the route's regex matches URL — we get route parameters from URL, otherwise we get null. For each matched route we create a RouteWithParams object and save it to an array. Now, if we have 0 or 1 matching routes, everything is simple. However, if more than one route matches the current location, we have to decide which one has higher priority. To solve this, we use ambiguousness field. As you might remember, it's a number of parameter this route has, and a route with the lowest ambiguousness is prioritized.

For example, if we had two routes /app/dashboard and /app/:section, location http://example.com/app/dashboard would match both routes. But it's pretty obvious that this URL should correspond to /app/dashboard route, not /app/:section.

This algorithm isn't bulletproof though. For example, routes /app/:user/settings/:section and /app/dashboard/:section/:region both will match URL http://example.com/app/dashboard/settings/asia. And since they have the same ambiguousness level, our router won't be able to decide which one should be prioritized.

Now we need to glue this code together to react to location changes and update currentRoute variable;

const areRoutesEqual = <A extends RawRoute, B extends RawRoute>(
    a: RouteWithParams<A> | undefined, 
    b: RouteWithParams<B> | undefined
): boolean => {
    if (!a && !b) return true; // Both are undefined
    if ((!a && b) || (a && !b)) return false; // Only one is undefined
    if (!a!.matches(b!.route)) return false; // Different routes
    // Same routes, but maybe parameters are different?
    const allParamsMatch = a.route.keys.every(key => a.params[key] === b!.params[key]);
    return allParamsMatch;
};
 
history.subscribe(() => {
    const newRoute = findMatchingRoute(history.getLocation());
    if (!areRoutesEqual(newRoute, currentRoute)) {
        currentRoute = newRoute;
        notifyRouteChange(); // Will be covered later
    }
});

Now our router reacts to location changes and the user can always get the current route, yay! But it's not very useful without the ability to subscribe to route changes, so let's add that. The approach is very similar to the one we used in history wrapper.

const subscribers: Set<VoidFunction> = new Set();
 
const subscribe = (cb: VoidFunction) => {
    subscribers.add(cb);
    return () => void subscribers.delete(cb);
};
 
const notifyRouteChange = () => {
    subscribers.forEach(cb => {
        try {
            cb();
        } catch (err) {
            console.error('Error in route change subscriber', err);
        }
    });
};

To perform navigation, we'll expose navigate and navigateUnsafe functions, which are a simple wrapper around history.push and history.replace:

// This function accepts any string path (no type-safety)
const navigateUnsafe = (
    path: string, 
    { action = 'push' }: { action?: 'push' | 'replace' } = {}
) => {
    history[action]({}, '', path)
};
 
// And this function accepts only paths that correspond to one of routes
const navigate = (
    path: Path<RouteType>, 
    options: { action?: 'push' | 'replace' } = {}
) => {
    navigateUnsafe(path, options);
};

Well, now that's a real router! Very bare-bones, but working nonetheless. We still have some hooks and components to implement, but it gets much easier from here.

Step 2: Hooks

For hooks, we can start with simple ones that return current location and current route. They are quite easy on their own, but useSyncExternalStore turns them into a one-liner. The way we designed our imperative API earlier allowed us to drastically reduce code for these hooks.

const useLocation = () => {
    return useSyncExternalStore(history.subscribe, history.getLocation);
};
 
const useCurrentRoute = () => {
    return useSyncExternalStore(subscribe, getCurrentRoute);
};

When coding components that are supposed to be rendered only on specific route/set of routes, you can use useCurrentRoute to get the current route, check if it matches the criteria and then use its parameters (or throw an error). But this is such a common task that it will be a crime to make our users write their own hook for that - our router should provide this out of the box.

function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: true): RouteWithParams<T>;
function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict: false): RouteWithParams<T> | undefined;
function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: boolean): RouteWithParams<T> | undefined {
    const currentRoute = useCurrentRoute();
    const normalizedFilter = Array.isArray(filter)
        ? filter
        : isParsedRoute(filter)
            ? [filter]
            : Object.values(filter);
    const isMatching = !!currentRoute 
        && normalizedFilter.some(route => currentRoute.matches(route));
    if (isMatching) return currentRoute as RouteWithParams<T>;
    else {
        if (strict === false) return undefined;
        throw new RouteMismatch(
            `Current route doesn't match provided filter(s)`
        );
    }
}

This hook has two versions: strict and relaxed. If user passes true as second parameter (or doesn't pass anything, as true is the default value) this hook will throw an error if the current route doesn't match one of the provided filters. This way you can be sure that the hook will return a matching route or not return at all. If the second parameter is false, instead of throwing an exception, the hook will simply return undefined if the current route doesn't match filters.

To describe this behavior to TypeScript we use a feature called function overloading. This allows us to define multiple function definitions with different types, and TypeScript will automatically pick one to be used when the user calls such function.

In addition to path parameters, some data might be passed in search parameters, so let's add a hook to parse them from string into mapping. For this we'll use built-in browser API URLSearchParams.

const useSearchParams = () => {
    const location = useLocation();
    return useMemo(() => {
        return Object.fromEntries(
            (new URLSearchParams(location.search)).entries()
        );
    }, [location.search]);
};

And the last hook in this section is useNavigationBlocker which is also quite simple: it will just accept callback and wrap calls to history.addBlocker into an effect, to re-attach the blocker if it changes.

const useNavigationBlocker = (cb: NavigationBlocker) => {
    useEffect(() => {
        return history.addBlocker(cb);
    }, [cb]);
};

Now let's jump into components!

Step 3: Components

What is the first component that comes to mind when mentioning routing libraries? I bet it's Route or, at least, something similar. As you saw previously, our hooks were very simple due to a well-designed imperative API which does all the heavy lifting. Same goes for components, they can be easily implemented by the user in a few lines of code. But we're a serious routing library out there, let's include batteries in the box :)

type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> };
 
const Route = ({ component: Component, match }: RouteProps) => {
    const matchedRoute = useRoute(match, false);
    if (!matchedRoute) return null;
    return (<Component />);
};

Well, that was easy! Wanna guess how we implement the NotFound component? :)

type NotFoundProps = { component: ComponentType };
 
const NotFound = ({ component: Component }: NotFoundProps) => {
    const currentRoute = useCurrentRoute();
    if (currentRoute) return null;
    return (<Component />);
};

And the last component required for our router is Link, which is a bit more tricky. You can't just use <a href="/app/dashboard" /> as it will always initiate hard navigation and doesn't provide any typesafety. So let's address these issues:

type LinkProps = Omit<ComponentProps<"a">, 'href'>
    & (
        // Our link accepts either type-strict href
        // or relaxed unsafeHref
        { href: Path<RouteType>, unsafeHref?: undefined }
        | { href?: undefined, unsafeHref: string }
    ) & { action?: 'push' | 'replace' };
 
const Link = ({ 
    action = 'push', onClick, href, unsafeHref, ...props 
}: LinkProps) => {
    const hrefToUse = (href ?? unsafeHref)!;
    const targetsCurrentTab = props.target !== '_blank';
 
    const localOnClick: MouseEventHandler<HTMLAnchorElement> = (event) => {
        if (onClick) {
            onClick(event);
            if (event.isDefaultPrevented()) {
                // User-defined click handler cacnelled navigation, we should exit too
                return;
            }
        }
 
        const inNewTab = !targetsCurrentTab 
            || event.ctrlKey 
            || event.shiftKey 
            || event.metaKey 
            || event.button === 1;
        if (!isExternal && !inNewTab) {
            event.preventDefault();
            navigateUnsafe(hrefToUse, { action });
        }
    };
    const isExternal = useMemo(() => {
        if (!hrefToUse) return false;
        return new URL(hrefToUse, window.location.href).origin !== location.origin;
    }, [hrefToUse]);
 
    return <a {...props} href={hrefToUse} onClick={localOnClick} />
};

Similarly to navigate function, the Link component typechecks the URL that you pass to it, but also allows to provide an arbitrary string URL (as an escape hatch or for external links). To override <a>'s behavior, we attach our own onClick listener, inside which we'll need to call the original onClick (passed to our Link component). After that, we check if the navigation wasn't already aborted by developer (if it was, we should ignore the event). If all is good, we check if the link isn't external and if it should be open in current tab. And only then we cancel the built-in hard navigation and instead call our own navigateUnsafe function.

And now, we just need to return all our functions, hooks and components (along with a few functions re-exported directly from history) from the createRouter function.

return {
    // Directly re-exported from history
    go: history.go,
    back: history.back,
    forward: history.forward,
    addBlocker: history.addBlocker,
    getLocation: history.getLocation,
    subscribeToLocation: history.subscribe,
    // Imperative API
    subscribe,
    getCurrentRoute,
    navigate,
    navigateUnsafe,
    // Hooks
    useLocation,
    useCurrentRoute,
    useRoute,
    useSearchParams,
    useNavigationBlocker,
    // Components
    Link,
    Route,
    NotFound,
};

And with that, our router is done. Now we can build our teeny-weeny app to showcase our teeny-weeny router we just made! By the way, you can find the complete code for this router (including the code for an example app) here.

Putting the puzzle pieces together

So, how does all this code tie up together? Quite neatly, if I do say so myself! Imagine you're making a note-taking app. Firstly, you would start with defining the routes and creating a router like this:

export const routes = defineRoutes({
    // Yep, this will be very minimal app
    root: '/',
    newNote: '/new',
    note: '/note/:noteId',
});
 
const router = createRouter(routes);
 
// Export functions and component you will be using
export const { 
    navigate, Link, Route, NotFound, useRoute, useNavigationBlocker 
} = router;

And then you link the routes you've defined with the page components:

function App() {
    return (
        <div className="app">
            <div className="links">
                {/* This is how you build URL for Link */}
                <Link href={routes.root.build({})}>View all notes</Link>
                <Link href={routes.newNote.build({})}>Create new</Link>
            </div>
            <Route match={routes.root} component={NotesListPage} />
            <Route match={routes.newNote} component={NewNotePage} />
            <Route match={routes.note} component={NoteDetailsPage} />
            <NotFound component={NotFoundPage} />
        </div>
    )
}

When in NoteDetailsPage, you need to get a note ID from the url, so you use a useRoute hook:

export const NoteDetailsPage = () => {
    const { getNote } = useNotes();
    const { params } = useRoute(routes.note);
    const note = getNote(params.noteId);
 
    return note ? (<>
        <h1>{note.title}</h1>
        <div>{note.text}</div>
    </>) : (<h1>Not found</h1>);
};

And when creating a new note, you probably would like to confirm the user's intent if they navigate away without saving the note:

export const NewNotePage = () => {
    const saveNote = () => {
        isSubmittingNoteRef.current = true;
        const note = createNote(title, text);
        // And this is programmatic redirect
        navigate(routes.note.build({ noteId: note.id }));
        isSubmittingNoteRef.current = false;
    };
    
    const [title, setTitle] = useState('');
    const [text, setText] = useState('');
    const { createNote } = useNotes();
    const isSubmittingNoteRef = useRef(false);
 
    useNavigationBlocker((isSoftNavigation) => {
        const dirty = title !== '' || text !== '';
        if (!dirty || isSubmittingNoteRef.current) return false;
        if (isSoftNavigation) {
            const confirmation = confirm('Do you want to leave?');
            return !confirmation;
        } else {
            return true;
        }
    });
 
    return <>
        <h1>New note</h1>
        <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
        <textarea placeholder="Text" value={text} onChange={e => setText(e.target.value)} />
 
        <button onClick={saveNote}>Save</button>
    </>;
};

Possible improvements

While our router does indeed route, it's no match for production-ready solutions like TanStack router, react-router or Next.js router. I mean it's just ~500 lines of code, that's not much. But what exactly is missing?

First of all, Server Side Rendering. Today not all apps might need SSR, but all routing libraries are expected to support it. Adding server side rendering into a string (not streaming SSR!) will involve creating a different history which will store the current location in memory (as there is no History API on server) and plug that into the createRouter function. I'm not aware about how hard it will be to implement streaming SSR, but I assume it will be strongly connected with support of Suspense.

Second, this router doesn't integrate with concurrent rendering well. Mostly because of our use of useSyncExternalStore, as it isn't compatible with non-blocking transitions. It works this way to avoid tearing: a situation where a part of UI has rendered with a particular store value, but the rest of the UI is rendered with a different one. And because of this, the router doesn't integrate well with Suspense, as for every location update that suspends, a fallback will be shown. By the way, I covered concurrency in React in this article and in this one I talk about Suspense, data fetching and use hook.

But even with these downsides, I hope you found this article interesting and built your own router along the way :)

Published at 6 March 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.

You may also like: