In my previous article , we have seen what are the new APIs of react-router
v6. We also have listed what we expect to develop.
In this article, we won’t implement the nested Route
and Routes
, but don’t be afraid it will be done in a next article.
The goal is to be able to implement something like this:
function App() {
return (
<Router>
<Routes>
<Route path="hobby/" element={<HobbyListPage />} />
<Route
path="hobby/:name"
element={<HobbyDetailPage />}
/>
<Route path="about" element={<AboutPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Router>
);
}
With a set of utility hooks:
// To get the current location pathanme, query params and anchor
function useLocation();
// To get the path variables
function useParams();
// To push or replace a new url
// Or to go forward and backward
function useNavigate();
Let’s start with the Router
component
This component is the main one. It will provide the location
and methods to change the url, to components below it (in the tree).
react-router
provides two router BrowserHistory
(using the browser’s history) and MemoryHistory
(the history will be stored in memory).
In this article, we will only develop a BrowserHistory
.
The location and navigation methods will be stored in a React context. So let’s create it and code the provider:
import React from 'react';
const LocationContext = React.createContext();
export default function Router({ children }) {
return (
<LocationContext.Provider
value={{
// The current location
location: window.location,
navigator: {
// Change url and push entry in the history
push(to) {
window.history.pushState(null, null, to);
},
// Change url and replace the last entry in the history
replace(to) {
window.history.replaceState(null, null, to);
},
// Go back to the previous entry in the history
back() {
window.history.go(-1);
},
// Go forward to the next entry in the history
forward() {
window.history.go(1);
},
// If we want to go forward or
// backward from more than 1 step
go(step) {
window.history.go(step);
},
},
}}
>
{children}
</LocationContext.Provider>
);
}
If you try to use these methods to change the url, you will see that it doesn’t work.
If you try to play with this code and watch logs, you will see that the component does not render so any component that uses the location
will not be informed of the new url.
The solution is to store the location in a state and change it when we navigate through the pages.
But we can’t just push the window.location
in this state, because in reality the reference of window.location
does not change the reference of the object but the object is mutated. So if we do this, it will just do nothing.
So we are going to build our own object, and put the values of pathname
, search
and hash
.
Here is the function to create this new location
object:
function getLocation() {
const { pathname, hash, search } = window.location;
// We recreate our own object
// because window.location is mutated
return {
pathname,
hash,
search,
};
}
The creation of the state is:
const [location, setLocation] = useState(getLocation());
Then we just have to change the state when we navigate, for example when we push:
push(to) {
window.history.pushState(null, null, to);
setLocation(getLocation());
}
We could do the same for the methods which navigate in the history entries. But it will not work when we go back or forward with the browser buttons.
Fortunately, there is an event that can be listened for this use case. This event popstate
is fired when the user navigates into the session history:
useEffect(() => {
const refreshLocation = () => setLocation(getLocation());
window.addEventListener('popstate', refreshLocation);
return () =>
window.removeEventListener('popstate', refreshLocation);
}, []);
Finally we got the following for our Router
:
import React, {
useContext,
useEffect,
useMemo,
useState,
} from 'react';
const LocationContext = React.createContext();
function getLocation() {
const { pathname, hash, search } = window.location;
// We recreate our own object
// because window.location is mutated
return {
pathname,
hash,
search,
};
}
export default function Router({ children }) {
const [location, setLocation] = useState(getLocation());
useEffect(() => {
const refreshLocation = () => {
setLocation(getLocation());
};
// Refresh the location, for example when we go back
// to the previous page
// Even from the browser's button
window.addEventListener('popstate', refreshLocation);
return () =>
window.removeEventListener(
'popstate',
refreshLocation,
);
}, []);
const navigator = useMemo(
() => ({
push(to) {
window.history.pushState(null, null, to);
setLocation(getLocation());
},
replace(to) {
window.history.replaceState(null, null, to);
setLocation(getLocation());
},
back() {
window.history.go(-1);
},
forward() {
window.history.go(1);
},
go(step) {
window.history.go(step);
},
}),
[],
);
return (
<LocationContext.Provider
value={{
location,
navigator,
}}
>
{children}
</LocationContext.Provider>
);
}
Now we can implement, some simple hooks which will use this LocationContext
. We are going to develop:
useLocation
: to get the locationuseNavigator
: to get the navigator part
The implementations are the following ones:
function useLocation() {
return useContext(LocationContext).location;
}
function useNavigator() {
return useContext(LocationContext).navigator;
}
It’s time to continue our implementation with the Route
component. The API is simple, it takes:
- the
element
to display - the
path
for which this route will be displayed
And the implementation is quite simple:
function Route({ element, path }) {
return element;
}
As you can see the path
prop is not used in this component, but by the Routes
component which decides if this Route
should be displayed or not.
And this our next part.
As I said previously, the Routes
component decides which Route
to display in function of the location.
Because I don’t want this article to be too long and difficult. In this part, we are just going to do routing with no nested Route
and Routes
.
But don’t be afraid, in an other article I will code all the features wanted.
Now that we know the scope of this article, let’s go put our hands in some code.
We know that a Routes
takes all the possible Route
as children. From this children, we can loop through this children to extract the path
of each Route
from its props to build a simple array of objects, which easier to process than a React element.
So we want to make a function buildRouteElementsFromChildren
that will return an Array of:
type RouteElement = {
path: string;
element: ReactNode;
children: RouteElement[];
};
The code of this function is:
function buildRouteElementsFromChildren(children) {
const routeElements = [];
// We loop on children elements to extract the `path`
// And make a simple array of { elenent, path }
React.Children.forEach(children, (routeElement) => {
// Not a valid React element, let's go next
if (!React.isValidElement(routeElement)) {
return;
}
const route = {
// We need to keep the route to maybe display it later
element: routeElement,
// Let's get the path from the route props
// If there is no path, we consider it's "/"
path: routeElement.props.path || '/',
};
routeElements.push(route);
});
return routeElements;
}
If we take the following Routes
example:
<Routes>
<Route path="hobby/:name" element={<HobbyDetailPage />} />
<Route path="hobby" element={<HobbyListPage />} />
<Route path="about" element={<AboutPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
Will be transformed into:
[
{
path: 'hobby/:name',
element: <HobbyDetailPage />,
},
{
path: 'hobby',
element: <HobbyListPage />,
},
{
path: 'about',
element: <AboutPage />,
},
{
path: '/',
element: <HomePage />,
},
];
Ok, now that we have a simple object, we need to find the first matching Route
from this object.
We already now all the possible paths. And thanks to the useLocation
, we know the current pathname
.
Before doing some code. Let’s think about it.
Unfortunately, we can’t just compare the current pathname
to the Route ones because we have path variables.
Yeah, I guess you already know that we are going to use Regexp :/
For example, if we are at the location /hobby/knitting/
named currentPathname
, we want the following path to match:
hobby/:name
/hobby/:name
/hobby/:name/
hobby/:name/
For the leading slash we are going to put a slash before the path, and replace all double slash by one:
`/${path}`.replace(/\/\/+/g, '/');
For the trailing slash, we are to put an optional trailing slash in the regex:
new RegExp(`^${regexpPath}\\/?$`);
Now the question is, what is the value of regexpPath
. The regex has two objectives:
- get the path variable name (after the
:
), here it isname
- get the value associated to it, here it is
knitting
// We replace `:pathVariableName` by `(\\w+)`
// A the same time we get the value `pathVariableName`
// Then we will be able to get `knitting` for
// our example
const regexpPath = routePath.replace(
/:(\w+)/g,
(_, value) => {
pathParams.push(value);
return '(\\w+)';
},
);
Now, that we have seen the complexity, let’s make some code:
// This is the entry point of the process
function findFirstMatchingRoute(routes, currentPathname) {
for (const route of routes) {
const result = matchRoute(route, currentPathname);
// If we have values, this is the one
if (result) {
return result;
}
}
return null;
}
function matchRoute(route, currentPathname) {
const { path: routePath } = route;
const pathParams = [];
// We transform all path variable by regexp to get
// the corresponding values from the currentPathname
const regexpPath = routePath.replace(
/:(\w+)/g,
(_, value) => {
pathParams.push(value);
return '(\\w+)';
},
);
// Maybe the location end by "/" let's include it
const matcher = new RegExp(`^${regexpPath}\\/?$`);
const matches = currentPathname.match(matcher);
// The route doesn't match
// Let's end this
if (!matches) {
return null;
}
// First value is the corresponding value,
// ie: currentPathname
const matchValues = matches.slice(1);
return pathParams.reduce(
(acc, paramName, index) => {
acc.params[paramName] = matchValues[index];
return acc;
},
{
params: [],
element: route.element,
// We want the real path
// and not the one with path variables (ex :name)
path: matches[0],
},
);
}
Now that we can get the matching route. We are going to render the Route
and use a React context name ReuteContext
to put the params
.
The Routes
component is:
const RouteContext = React.createContext({
params: {},
path: '',
});
function Routes({ children }) {
// Get the current pathname
const { pathname: currentPathname } = useLocation();
// Construct an Array of object corresponding to
// available Route elements
const routeElements =
buildRouteElementsFromChildren(children);
// We want to normalize the pahts
// They need to start by a "/""
normalizePathOfRouteElements(routeElements);
// A Routes component can only have one matching Route
const matchingRoute = findFirstMatchingRoute(
routeElements,
currentPathname,
);
// No matching, let's show nothing
if (!matchingRoute) {
return null;
}
const { params, element, path } = matchingRoute;
return (
<RouteContext.Provider
value={{
params,
path,
}}
>
{element}
</RouteContext.Provider>
);
}
And now we need our hook to get the params:
const useParams = () => useContext(RouteContext).params;
Thanks to the useNavigator
hook, we can access to methods to navigate between page.
But the development experience is not necessarily the best. For example:
- Currently, the path is
/hobby
- I push,
knitting
- I would like the new path to be
/hobby/knitting
And:
- Currently, the path is
/hobby/knitting
- I push,
/about
- I would like the new path to be
/about
So, to meet these two needs we are going to develop a hook useResolvePath
which returns us the right path, a hook useNavigate
and a component Link
to navigate where we want easily.
// For none typescript developers
// The `?` means it's optional
type To =
| {
pathname?: string;
search?: string;
hash?: string;
}
| string;
And in the code we should transform to
as object to string and vice versa, but I repeat myself I’m just gonna work with string in this article for simplicity.
To resume the strategy if the path to resolve is starting with a /
then it’s an absolute path otherwise a relative path to actual one.
We can get the actual path, thanks to useRouteContext
.
Let’s implement this:
// Concat the prefix with the suffix
// Then we normalize to remove potential duplicated slash
function resolvePathname(prefixPath, suffixPath) {
const path = prefixPath + '/' + suffixPath;
return normalizePath(path);
}
// We export the utility method
// because we need it for other use cases
export function resolvePath(to, currentPathname) {
// If the to path starts with "/"
// then it's an absolute path
// otherwise a relative path
return resolvePathname(
to.startsWith('/') ? '/' : currentPathname,
to,
);
}
export default function useResolvePath(to) {
const { path: currentPathname } = useRouteContext();
return resolvePath(to, currentPathname);
}
Then we can develop our useNavigate
hook and Link
component thanks to that :)
We are going to start with the hook to use it in the component.
This hook will return a callback with the parameters:
- First parameter:
to
which is a string (the url to navigate to) or a number if we want to go backward or forward. - Second parameter: an object of options. For the article the only option will be
replace
if the user just want to replace the url (push
by default).
Let’s make some code:
function useNavigate() {
const navigator = useNavigator();
// We want to know the current path
const { path: currentPath } = useRouteContext();
// By default it will push into the history
// But we can chose to replace by passing `replace` option
// You can pass a number as `to` to go `forward` or `backward`
return useCallback(
(to, { replace = false } = {}) => {
// If to is a number
// we want to navigate in the history
if (typeof to === 'number') {
navigator.go(to);
} else {
// We want to get the "real" path
// As a reminder if
// to starts with / then it's an absolute path
// otherwise a relative path in relation to currentPath
const resolvedPath = resolvePath(to, currentPath);
(replace ? navigator.push : navigator.push)(
resolvedPath,
);
}
},
[navigator, currentPath],
);
}
We want to be able to open a new tab from our element, and to have the same behavior than a a
tag. So let’s use a a
with a href
property.
But if we just do that, the browser will load the page and refetch assets (css, js, … files). So we need to prevent this default behavior, we are going to put an onClick
method and preventDefault
the event.
function Link({ to, children, replace = false }) {
const navigate = useNavigate();
// We want to get the href path
// to put it on the href attribtue of a tag
// In the real inplementation there is a dedicated hook
// that use the `useResolvePath` hook
// and transform the result into string
// (because potentially an object but not in this article)
const hrefPath = useResolvePath(to);
// We put the href to be able to open in a new tab
return (
<a
href={hrefPath}
onClick={(event) => {
// We want do not browser to "reload" the page
event.preventDefault();
// Let's navigate to `to` path
navigate(to, { replace });
}}
>
{children}
</a>
);
}
And here we go, we can navigate to new pages.
Here is a little code sandbox of this second part of react-router
implementation:
In this article, we have coded the base to make a react-router
like library. The main goal is to understand how works the main routing library for React
, in its next version 6.
To resume what we have learned and done in this second article about react-router
v6:
- The
Router
provides the location and methods to navigate through pages. - The
Route
corresponding to a specific page / path - The
Routes
component determines theRoute
to display, and provides the current pathname of theRoute
and theparams
.
Let’s meet in my next article which will implement nested Route
and Routes
, and also bonus hooks.
If you want to see more about react-router
v6 which is in beta yet, let’s go see the migration guide from v5 .
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.