I've been using react-router-dom
for years now, and while it's great, there’s always room for improvement, especially in terms of stability and type safety. Let’s go through an evolution of how we can make navigation more robust using TypeScript.
Basic Implementation
A simple react-router-dom
setup looks like this:
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/order/:id", element: <OrderPage /> },
// etc...
]);
We provide the router through a provider, and then use useNavigate
as usual to navigate between pages:
export const LoginPage = () => {
const navigate = useNavigate();
const handleNavigateOrder = (id: string) => {
navigate(`/order/${id}`);
};
return <>...</>;
};
This works fine in most cases, but what if we want more stability and type safety?
Moving Routes to Constants
A good first step is to extract route strings into constants. We create a constants/routes.ts
file:
export const APP_ROUTES = {
LOGIN: "/login",
ORDER: "/order",
} as const;
Why
as const
? This ensures thatAPP_ROUTES
is treated as a readonly object. Unlike using anenum
, which compiles to an object at runtime,as const
keepsAPP_ROUTES
immutable. This approach avoids unnecessary runtime overhead but also providing type safety.
Now, we use it in our navigation function:
const handleNavigateOrder = (id: string) => {
navigate(`${APP_ROUTES.ORDER}/${id}`);
};
This is better, but something still feels off. Manually formatting the string every time? There must be a better way!
Introducing a Custom Hook for Navigation
Let’s create a useAppNavigate
hook that automatically builds paths from parameters.
const buildPath = (path: string, params?: Record<string, string>): string => {
if (!params) return path;
return path.replace(/:(\w+)/g, (_, key) => params[key] || '');
};
export const useAppNavigate = () => {
const navigate = useNavigate();
return (path: string, params?: Record<string, string>) => {
navigate(buildPath(path, params));
};
};
This buildPath
function takes a base path and a params object, replacing :param
placeholders with actual values. Now we can use our hook like this:
export const LoginPage = () => {
const navigate = useAppNavigate();
const handleNavigateOrder = (id: string) => {
navigate(APP_ROUTES.ORDER, { id });
};
return <>...</>;
};
Improving Type Safety
But wait! What if we misspell a parameter? The function will silently fail. Time to actually use TypeScript like a not normal person.
First, let’s ensure only valid route keys can be used:
export const useAppNavigate = () => {
const navigate = useNavigate();
return (path: keyof typeof ROUTES, params?: Record<string, string>) => {
navigate(buildPath(ROUTES[path], params));
};
};
Next, let’s define a RouteParams
type that maps route keys to their expected parameters:
type RouteParams = {
LOGIN: never;
ORDER: { id: string };
VIEW_ORDER: { id: string };
CATALOG: never;
};
Now, our useAppNavigate
function becomes:
export const useAppNavigate = () => {
const navigate = useNavigate();
return <T extends keyof RouteParams>(path: T, params?: RouteParams[T]) => {
navigate(buildPath(ROUTES[path], params));
};
};
Automatically Extracting Route Parameters
Let’s take it a step further and automatically extract parameters from route strings. Look at this monstruosity... But don't worry, I'm good at explaining things and put some comments for you
type ExtractParams<T extends string> =
// Check if the route has multiple parameters separated by `/`
T extends `${string}/:${infer Param}/${infer Rest}`
? {
// Recursively extract all parameters
[K in Param | keyof ExtractParams<Rest>]: string
}
// If there's only one parameter left to extract
: T extends `${string}/:${infer Param}`
? {
// Capture it as a string key
[K in Param]: string
}
// If no parameters exist, return never
: never;
So now we can extract parameters from route paths dynamically. Also we need redefine RouteParams
like this:
type RouteParams = {
[K in keyof typeof ROUTES]: ExtractParams<(typeof ROUTES)[K]>;
};
And the final version of useAppNavigate
:
const buildPath = <T extends keyof RouteParams>(
path: (typeof ROUTES)[T],
params?: RouteParams[T]
): string => {
if (!params) return path;
return path.replace(/:(\w+)/g, (_, key) => {
return params[key as keyof RouteParams[T]] as string;
});
};
export const useAppNavigate = () => {
const navigate = useNavigate();
const handleNavigate = <T extends keyof RouteParams>(
path: (typeof ROUTES)[T],
params?: RouteParams[T]
) => {
navigate(buildPath(path, params));
};
return handleNavigate;
};
Now, we have fully type-safe, parameterized navigation! 🎉
Make sure to keep your ROUTES
in a separate file so typescript won't yell at you because of a unsupported exports or something...
That's all for today, take this snippet and refactor your navigation if you want. Thanks for reading!
Top comments (1)
Nice article! Using TypeScript to enforce type safety in navigation is a smart move. The approach of extracting parameters dynamically from routes with
ExtractParams
is pretty clever.