Web-App Routing is FUN - the Web is weird, but fun

nombrekeff

Keff

Posted on October 18, 2023

Web-App Routing is FUN - the Web is weird, but fun

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`;
Enter fullscreen mode Exit fullscreen mode

It works, but we need to set the whole URL instead of just changing the path.

  • changing the location.pathname:
location.pathname = `/home`;
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

It will change the path from /home/ideas to /home/contact. This can be done very easily by using the history.pushState()

history.pushState(null, null, './contact');
Enter fullscreen mode Exit fullscreen mode

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'));
};
Enter fullscreen mode Exit fullscreen mode
  1. Grab a reference to the real history.pushState
  2. Reasign history.pushState to a new function.
  3. Call the real pushState function with its correct context.
  4. 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!!!
});
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

This uses the history.pushState in the background to update the URL in the browser, and, as we've overridden the pushState method, the pushstate event will be fired.

Behind the scenes, makeRouter does a couple of things to make everything work:

  1. As seen previously in the article, we override the history.pushState function.
  2. It listens to pushstate and popstate events:
window.addEventListener('popstate', () => updatePage());
window.addEventListener('pushstate', () => updatePage());
Enter fullscreen mode Exit fullscreen mode
  1. It grabs the current path
  2. 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',
});
Enter fullscreen mode Exit fullscreen mode

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: () => {...}
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
nombrekeff
Keff

Posted on October 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related