Recently, I was inspired by a tweet by Joel Califa to attempt to build a simple router in React. The goal was to end up with three things:
My first instinct was that I could just use existing browser history event APIs. As long as I have a PageLink
component that calls history.pushState(path)
when clicked, there must be some event that a router context can listen in on when that happens. Unfortunately, this isn't the case! The browser provides a popstate
event, but that's only called as a result of user action (such as clicking the back button) or some other history APIs. We will end up using popstate
, eventually, though, so stay tuned, popstate
fans!
First, let's set up the basic application skeleton. We'll have an App
component that renders a different page, depending on the current route. Sketched out roughly, it looks something like this:
import Home from "./pages/Home";
import About from "./pages/About";
import NotFound from "./pages/NotFound";
export default function App() {
// Hand-wavy "get the current route state"
const route = getRoute();
switch (route) {
case "/":
return <Home />;
case "/about":
return <About />;
default:
return <NotFound />;
}
}
Pretty simple! We get the route (somehow) and render the appropriate page based on that route.
We also need some way of linking between these pages. For that, we'll create a PageLink
component:
export default function PageLink(props) {
return (
<a
href={props.path}
onClick={(evt) => {
evt.preventDefault();
setRoute(props.path); // Hand-wavy
}}
>
{props.children}
</a>
);
}
This component just renders its children inside of a link. When clicked, we'll have to come up with some way for it to set the route to the path
property. Note that we want to avoid passing state around, so we're going to rule out anything like props.setRoute
. In addition, we want to be able to put these page components in separate files, so relying on route
and setRoute
being in scope and using closures is out of the picture, as well.
Thankfully, React has a very useful tool for when we want to pass data through the component tree without having to pass props down manually at every level. It's called Context, and its documentation states that it "provides a way to pass data through the component tree without having to pass props down manually at every level"!
In order to make use of React Context, we're going to build a component called a Provider. Essentially, the Provider component is what will allow Consumer components, which we'll build later, to consume (and update) the routing state! First we'll create our Context object and our Provider component:
// Just create an empty context, we'll give it data later.
export const RouterContext = React.createContext();
/* Technically, we could just put this in our `App` component, but I like doing it this way, where only this simple wrapper component has the raw state value and its setter in it. */
export function RouterProvider(props) {
// The initial state for the Provider will always be the current location.
const [route, setRoute] = useState(location.pathname);
return (
<RouterContext.Provider
// Set the initial state: A `route` value, and a `setRoute` function
value={{
route: route,
setRoute: (path) => {
history.pushState(null, "", path);
setRoute(path);
},
}}
>
{props.children}
</RouterContext.Provider>
);
}
This RouterProvider
component uses RouterContext.Provider
component in the context object we created. It encapsulates one piece of state: the route
itself, which we'll set up soon. In addition to the route
property, the component also provides a setRoute
function. Note, however, that we're not just blindly passing the setRoute
function created by useState(location.pathname)
. This would work for state-management purposes, but we also want to ensure that setting the route also updates the current URL! In order to do that, the setRoute
function in the provider calls the browser's history API and "pushes" the new route into the history stack via history.pushState(null, '', path)
. After that, then it updates its route state by calling the original setRoute
function provided by useState
.
Now that we have the provider set up, we'll use it (and the consumer) in our application component to get the current route for rendering the proper page component:
+ import { RouterProvider, RouterContext } from "./router";
import Home from "./pages/Home";
import About from "./pages/About";
import NotFound from "./pages/NotFound";
export default function App() {
/* We want to render the provider once at the highest necessary level for consuming it */
return (
+ <RouterProvider>
+ {/* We can use the consumer now that the provider is setup */}
+ <RouterContext.Consumer>
+ {({ route }) => {
switch (route) {
case "/":
return <Home />;
case "/about":
return <About />;
default:
return <NotFound />;
}
+ }}
+ </RouterContext.Consumer>
+ </RouterProvider>
);
}