Web-App Routing is FUN - the Web is weird, but fun
Keff
Posted on October 18, 2023
Have you ever wondered how Routers work in frameworks and such? I did not. Until I had to write one for Cardboard. I discovered that they're actually pretty fun to build, and not at all weird or hacky.
Well, I've lied a bit. They're weird and a bit hacky, but pretty fun to figure out. I also more or less understood how they worked (after years of using many of them), but not what goes into them in the backend.
The basic concept is that whenever we navigate to a new URL path, we need to show some content that's linked to that path. But do not reload the page, just change the content.
For example, if navigating to /home
, we need to show the content for the /home
route. If navigating to the /about
page, we need to show the content of the /about
route. And of course, when we show content, we must hide the previous content.
That's the basics of what a router does. There's more than that, but I'll get to that.
Detecting path changes?
One thing we need before being able to create the router we must first understand how URL changes work and how we can detect when a path change happens.
What do I mean by path change? Well, changing the pathname part of the URL. For example, from this https://test.com/
to this https://test.com/home
.
How do we do this in JS? There are a couple of ways of doing this, for example:
- setting the
location.href
directly:
location.href = `https://test.com/home`;
It works, but we need to set the whole URL instead of just changing the path.
- changing the
location.pathname
:
location.pathname = `/home`;
The problem with this? You can't change the path relative to the current one. This means that, if you're at /home/ideas
, and want to change to /home/contact
. We must set the whole path: location.pathname = "/home/contact"
.
Another problem with this approach is that every time we change the location, the page will reload. You can imagine why this is not good for SPAs. For single-page apps, we don't want to reload the whole page each time the path changes.
- Using
history.pushState
The next option fixes this problem and allows us to change the path relatively. This is a win-win, the page does not reload, and we can modify the path relative to the current path.
Imagine we're at /home/ideas
, and we run this:
setPath('./contact');
It will change the path from
/home/ideas
to/home/contact
. This can be done very easily by using thehistory.pushState()
history.pushState(null, null, './contact');
Okay, that's cool and all, but how do we detect when the path changes? Well, HTML has an event that made me hopeful: popstate
. But it only fires when you go back in the history (i.e. page back).
And, of course, of course, there's no event for the
pushstate
... why would there be!
I might be wrong though, but I've not been able to find an event for when pushState
is called. If you know how please let me know!
So, what I've come up with is, to modify the pushState
function and inject some custom logic into it. I don't really like this approach as we're modifying stuff we should not, but it's the only way I've found.
This is what I mean:
const pushState = history.pushState;
history.pushState = (...args) => {
pushState.call(history, ...args);
window.dispatchEvent(new window.Event('pushstate'));
};
- Grab a reference to the real
history.pushState
- Reasign
history.pushState
to a new function. - Call the real
pushState
function with its correct context. - Emit an event on the window for
pushstate
Now we can listen to pushstate
events like the popstate
one:
window.addEventListener('pushstate', () => {
// The path has changed!!!
});
Reacting to path changes
Now that we can detect when the path changes we can add the logic that will change the contents of the page based on that path.
I will show a very basic example. But know that in reality there's a lot more stuff behind the scenes to make it work, and to make it more efficient.
The first thing we need is a way of configuring the routes of our app.
In this example, we'll have a function (makeRouter
) to handle that. I will not show the complete function as it's a bit long for the example.
makeRouter
needs a couple of things, an object with the content for each route we want to have (opts.routes
), and a selector to know where to put the contents (opts.parentSelector
).
routes
will be an object with the route path as a key and a function as the value. Imagine that these functions return Cardboard components. These components will represent some HTML and logic that will be on the page when the route is viewed.
const myRouter = makeRouter({
routes: {
'/home': () => {...},
'/about': () => {...},
},
parentSelector: 'body',
});
myRouter is an instance of the router object. It should also be possible to get a router from other parts of the app. Imagine there is some magic function (getRouter
) that returns the current router.
The router object allows us to navigate between routes:
myRouter.navigate('/home');
myRouter.navigate('/about');
This uses the
history.pushState
in the background to update the URL in the browser, and, as we've overridden thepushState
method, thepushstate
event will be fired.
Behind the scenes, makeRouter
does a couple of things to make everything work:
- As seen previously in the article, we override the
history.pushState
function. - It listens to
pushstate
andpopstate
events:
window.addEventListener('popstate', () => updatePage());
window.addEventListener('pushstate', () => updatePage());
- It grabs the current path
- Load the correct route for that path
Now, what happens if the route is not defined for a path? Currently, everything will break! But we can add some more features to our router to make it more secure and ergonomic.
Improving the router
First let's add a fallback route, for when a route is not found, we can default to that route.
Fairly easy, when creating the router, we must now ask for another option (opts.fallbackRoute
), which will just be the route path.
const myRouter = makeRouter({
parentSelector: 'body',
routes: {
'/home': () => {...},
'/about': () => {...},
},
fallbackRoute: '/home',
});
Now, if a route is not found, it will show the /home
route. We could also allow the makeRouter function to receive an option for a route builder (opts.noRouteBuilder
). This allows us to pass in a function for when the route is invalid:
const myRouter = makeRouter({
parentSelector: 'body',
routes: {
'/home': () => {...},
'/about': () => {...},
},
noRouteBuilder: () => {...}
});
Much better, now whilst we have fallbackRoute
and that route exits, or we have noRouteBuilder
we'll not have errors. But what if the fallback route does not exist? or no builder is passed? Well, currently, everything will break.
There are a few ways of handling this, and this should be up to each implementation. In my case, I decided that instead of throwing some error, or requiring the fallback or builder, I would just add an error to the page instead, indicating that the route does not exist!
How to show/hide content?
Well, this might be the most complex part of the process, and it all depends on the context where you're building the router. Is it a router for a framework that already exists? Does it need to work in vanilla JS? Is it the core router for your framework?
After knowing the context ask yourself: Can I do this already in some way? Can I show/hide content with the framework? Do I need to implement it myself? Is there some tool out there that can help me?
These are some of the questions you will need to ask yourself. And based on the answers you will do this in one way or another.
In my case, as I was building this for Cardboard, and had already implemented a way of adding and removing items from the page very easily and efficiently, I made use of that.
I will show a little pseudo-code example showcasing the updatePage
function mentioned above. This function will run each time the path changes (on pushstate
or popstate
).
updatePage() {
if(previousRoute) {
previousRoute.remove();
}
const route = getRoute();
route.add();
previousRoute = route;
}
This is extremely simple, but the concept is as follows:
- If there was a route already on the page remove it
- Then get the current route.
- Add the route (to the parent of the router, defined with
parentSelector
). - Set the
previousRoute
to the new route.
Additional Improvements
This example just scratched the surface, there are a lot more improvements that could be done to make it a fully fletched router. Here are some of them:
- Handling parameters (
/users/:id
) - Handling query parameters (
/users/:id?s=0
) - Caching contents
- Aliasing routes:
/
->/home
- Reusing elements from one page for the next (this could be the case for frameworks and more complex systems)
- Much, much more!
Summary
So yeah, I think that sums up what a router is and how to create one. Of course not in detail, it will not work if you just copy and paste. I think tutorials or guides like these should not just give you the answer, they should make you think, problem-solve, and more importantly learn. But give a clear view of how it works and show some of the oddities and quirks. I hope this article has done some of that for you!
Let me know your thoughts: Do you like this style of tutorial/guide? Or do you prefer a more step-by-step kind of article?
Cheers! That's me for this one. Have a nice one :)
Call to action!
Feedback needed: I'm currently working on Cardboard, pouring all my love, and free time into it (it's a lot, I'm unemployed). But I need early feedback to make sure it starts in the best way possible. Can I ask you to kindly head over to the Cardboard repo, take a look at the project, and give me some feedback? The project is open for anyone to help. You can contact me here on DEV, by mail: nombrekeff@gmail.com
, drop an issue, or comment on this post!
Posted on October 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.