React router is a package that I use in almost all of my projects. Not too long
ago, Michael Jackson tweeted this. This made me curious as to how difficult it would be to rebuild react router from scratch.
Before we dig into this I want to clarify that if you need a router in your project you should just use react router. It has a lot more features, handles a lot more edge cases and is incredibly well tested. This is purely a learning exercise.
In this post we are going to build a simplified version of react-router that is based on the newer v6 API's.
At the heart of react router is another package called 'history'. This package is responsible for managing the router history. For this post we are only concerned about creating a router for the web and so we are going to bake this directly into our react components. The first thing we are going to need is a root Router
component and a context for our other components to consume. Let's start with the context.
Our router is going to be much more simplified than react router in that we aren't going to provide support for location state, hashes and other cases that react router provides. Our router context is going to provide 2 keys; location and push:
- location is simply a string of the current path.
- push is a function which can be called to change the current path.
And with that we can create our basic router context.
const RouterContext = React.createContext({
location: "",
push: () => {},
});
This context is useless without rendering a provider. We are going to do that inside of our main Router
component. The responsibility of this component is to provide information about the current route and provide ways to manipulate it. We are going to store the current location path in react state. This way when we update the location our component will re-render. We also need to provide the push
function to our context which will simply update the browser location and update our location state. Finally we also listen for the window 'popstate' event to update our location when using the browser navigation buttons.
function Router({ children }) {
const [location, setLocation] = React.useState(window.location.pathname);
const handlePush = useCallback(
(newLocation) => {
window.history.pushState({}, "", newLocation);
setLocation(newLocation);
},
[]
);
const handleHashChange = useCallback(() => {
setLocation(window.location.pathname);
}, []);
useEffect(() => {
window.addEventListener("popstate", handleHashChange);
return () => window.removeEventListener("popstate", handleHashChange);
}, [handleHashChange]);
const value = useMemo(() => {
return { location, push: handlePush }
}, [location, handlePush])
return (
<RouterContext.Provider value={value}>
{children}
</RouterContext.Provider>
);
}
In order to test our component we are going to need a way to update the current route to check the correct components are rendering. Let's create a Link
component for this. Our link component will simply take a to
argument of the new path and call our push
function from the router context when clicked.
function Link({ to, children }) {
const { push } = React.useContext(RouterContext);
function handleClick(e) {
e.preventDefault();
push(to);
}
return (
<a href={to} onClick={handleClick}>
{children}
</a>
);
}
Now that we have a way to navigate around, we need a way to actually render some routes! Let's create a Routes
and Route
component to handle this. Let's start with the Route
component because all it needs to do is simply render the children we give it.
function Route({ children }) {
return children;
}
Next we need our Routes
component. Here we need to iterate through the route components and find one that matches the current location. We will also want to render the matched route inside of a route context, so that our route children can access any params that matched in the path. Let's start by creating the functions we need to match the routes. The first thing we need is a function that takes the path prop on a route and converts it into a regex that we can use to match against the current location.
function compilePath(path) {
const keys = [];
path = path.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return "([^\\/]+)";
});
const source = `^(${path})`;
const regex = new RegExp(source, "i");
return { regex, keys };
}
This will also give us an array of any keys that represet any params in the path pattern.
compilePath("/posts/:id");
// => { regex: /^(/posts/([^\/]+))/i, keys: ["id"] }
Next up we need a new function that will iterate through each child route and use the compilePath
function to test if it matches the current location, while also extracing any matching params.
function matchRoutes(children, location) {
const matches = [];
React.Children.forEach(children, (route) => {
const { regex, keys } = compilePath(route.props.path);
const match = location.match(regex);
if (match) {
const params = match.slice(2);
matches.push({
route: route.props.children,
params: keys.reduce((collection, param, index) => {
collection[param] = params[index];
return collection;
}, {}),
});
}
});
return matches[0];
}
Finally we can create a new RouteContext
and put together our Routes component. We'll pass the provided children into the matchRoutes
function to find a matching route and render it inside of a provider for the route context.
const RouteContext = React.createContext({
params: {},
});
function Routes({ children }) {
const { location } = useContext(RouterContext);
const match = useMemo(() => matchRoutes(children, location), [
children,
location,
]);
const value = useMemo(() => {
return { params: match.params }
}, [match])
// if no routes matched then render null
if (!match) return null;
return (
<RouteContext.Provider value={value}>
{match.route}
</RouteContext.Provider>
);
}
At this point we actually have a functioning router, however, we are missing a small but crucial piece. Every good router needs a way to extract parameters from the URL. Thanks to our RouteContext
we can easily create a useParams
hook that our routes can use to extract this.
function useParams() {
return useContext(RouteContext).params;
}
And with all of that we have our own basic working version of react router!
function Products() {
return (
<>
<h4>Example Products</h4>
<ul>
<li>
<Link to="/products/1">Product One</Link>
</li>
<li>
<Link to="/products/2">Product Two</Link>
</li>
</ul>
</>
);
}
function Product() {
const { id } = useParams();
return (
<>
<h4>Viewing product {id}</h4>
<Link to="/">Back to all products</Link>
</>
);
}
function App() {
return (
<Router>
<Routes>
<Route path="/products/:id">
<Product />
</Route>
<Route path="/">
<Products />
</Route>
</Routes>
</Router>
);
}
Top comments (15)
Great Effort !! brother 😁
I don't have much experience with useCallback,
Can you please explain this
How callback will detect that setLocation is changed !!
setLocation is just setter funciton right.
useCallback() hook is used to capture the whole function just a way to optimize the performance like as memoisation.
useCallback() used in conjunction with useEffect() because it allows you to prevent the re-creation of a function.
I hope this information helps you in understanding the useCallback() hook in react.
@Cheers
adnanaslam
Thanks brother!!
Thanks for pointing this out! You are correct! We don't need to pass setLocation here in the callback dependencies as it won't change. Will update the post now, thank you!
Great content and so few likes! That's because it's hard to understand in 5 mins :)
Thanks! Yeah definitely agree it's a lot for a quick read. I am thinking about also accompanying the post with a video tutorial which might be easier to digest.
Great post. I always wondered how React Routed worked internally, but never dived into the code. This article made it much clear to me.
Thanks
Great to hear! Thanks for the comment.
Great article!
I noticed a common ‘mistake’ in your code. I wrote about it here: medium.com/@ewestra/great-article-...
Great feedback! I'l be sure to update the post. Thank's for the comment!
Nice work. Thank's
Great Post !
Can you please explain where params in params[index] is defined?
Thanks.
🤦♂️ I was missing a line. Sorry about that! Have updated the post now. Thanks for the comment!
Awesome man, thanks a lot!
You're welcome! Glad you liked the read :) Hoping to do similar posts for other popular packages.