Back to Table of Contents

Nested and Dynamic Routes

What CoPilot thinks a cat looks like

By Jesse Pence

Introduction

We have full client-side routing, but we’re not doing much with it yet. Most notably, our products page doesn’t have any products on it! To define all of our products, we could place them in a JavaScript object inside the index.html. Perhaps that would be truer to the spirit of doing all of this in one file, but that’s not how real web sites work.

So instead, we will be building a fake database in our public folder that simply defines a few products and their details. We will then use the Web Fetch API to get the data from the database and display it on the page. We will be getting the items from a simple JSON file, but it could be a database, or a REST API, or anything else that returns data asynchronously. The principle is the same.

Before we define each of these things, let’s finish creating our view templates so that we actually have pages ready to fill with data.

View Templates

In our last chapter, we created a Routes configuration object that defined four static routes, Home, About, Products, and Cart. However, each view template contained some repetitive code that we used to manipulate the DOM. And, we want to be able to link from the products page to new pages that display the details of each product. While doing this, we will want to run our linkFinder function that we built in the previous chapter to make sure that our internal links work correctly.

To solve all of these problems at once, let’s separate our rendering logic into a separate function called render that takes a template as an argument. This function will return a string that we can then insert into the DOM. The linkFinder function included in the code below is the same one we employed in the previous chapter.

index.html

  function render(component) {
  // Since we only have one dynamic element on the page and very little content in common between pages...
  main.innerHTML = component
  // We can just replace the entire innerHTML of the main element...
  linkFinder()
  // And then run the linkFinder function to add event listeners to our links
}

Next, let’s make the actual view templates. First, we create two static view templates that are simple text with one photo. We will call them home and about. Then, we will create a product component that we will share between a products template to display all of our products and a product template to display individual products. Finally, we will create a 404 page with a different message for static and dynamic routes. The cart template will be updated in the next chapter.

index.html

  // Almost all of our final components
    const Home = () => {
      document.title = "Home" 
      // Set the page title
      render(`
      <p>This is a totally real shop! See! That's a picture! Of a shop! Totally
    open.</p>
    <img src="/diana.avif" alt="Check us out" />
      `)
    } // Hmm, this looks familiar.

    const About = () => {
      document.title = "About"
      render(`
      <h1>About</h1>
    <p>
      This is a totally real shop that sells totally real products. It's not a
      demonstration of how client-side routing works. Noo! It's just a
      shop.
    </p>
      `)
    } // It's a functional component!

    const ProductComponent = (product) => {
      return `
      <div class="product">
        <a href=/product/${product.id} class="Link">
          <img src="${product.image}" alt="${product.name}" />
        <h2>${product.name}</h2>
        </a>
        <p>${product.description}</p>
        <p>$${product.price}</p>
        <button class="add-to-cart" id="${product.id}">Add to cart</button>
      </div>
      `
    } // Oh look, we're passing props now.

    const ProductsPage = async () => {
      document.title = "Products"
      const products = await getProducts()
      // Get the products
      const productsHTML = Object.values(products)
        .map((product) => ProductComponent(product))
        .join("")
        // Map over the products and create a product component for each one
      render(productsHTML)
      // Render the products
      buttonFinderAdd()
    }

    const ProductPage = async (id) => {
      await getProducts()
      // Get the products
      const product = products.find((product) => product.id === Number(id))
      // Find the product that matches the id
      if (!product) {
        return Nope(id)
      }
      // 404 if no product is found
      document.title = product.name
      // Set the page title to the product name
      search.value = product.name
      // Set the search input value to the product name
      product ? render(ProductComponent(product)) : Nope(id)
      // If we find a product, render it. If not, render the 404 page.
      buttonFinderAdd()
    }

    const Nope = (id) => {
      if (id) {
        render(
          `<h1>404</h1><h2>Sorry buddy, but I don't think we have a product with id #${id}!</h2>`
        )
      } else {
        render(`<div>
        <h1>404</h1>
        <h2>Huh, you're at ${path}, but you really shouldn't be</h2>
      </div>`)
      }
    } // Only on v1 of this project, and I already have 2 404 pages... I think that's a good sign?

Whew! That was a lot! But, that is almost all of the mark-up for our entire app out of the way. But, wait… Where are we getting these products from? Oh yeah! We need to make a database!

Our Database

We will be using a file called db.json in our public folder. This will be a JSON file that contains an array of two whole products. Each product has an id, name, price, quantity, description, and image. We will be using the Web Fetch API to get this data from the file and display it on the page.

db.json

{
"products": [
  {
    "id": 1,
    "name": "Goofy board game",
    "price": 100,
    "quantity": 1,
    "description": "The best Disney movie now has a board game! Look at how evil they made the singer look for some reason.",
    "image": "/goofy.webp"
  },
  {
    "id": 2,
    "name": "Creepy toy",
    "price": 200,
    "quantity": 2,
    "description": "This toy is so creepy, it will give you nightmares.",
    "image": "/toy.webp"
  }
]
}

Web Fetch API

The Web Fetch API is an easy way to get data from a server. It is a modern replacement for XMLHttpRequest, so it is a promise-based API. That just means that it returns a promise that resolves to a response object. This response object contains the data that we want.

This will result in asynchronous code which was very hard to do right with JavaScript for a long time. You either used something like jQuery or ended up in callback hell. Now, it’s really easy. We can simply use the async/await syntax to make it look synchronous. We will be using this to get our data from the db.json file.

We’re going to reuse this code in a few places, so we will first create an empty object called products to cache the data so we can take advantage of our stateful application. We don’t have to call the server for every page like we would if we were creating a traditional server-side rendered application. This helps us avoid unnecessarily taxing the server, and we don’t expect our data to change very often so this won’t lead to any problems.

We will then create a function called db that returns a fetch to our pretend database. Next, we make another function called getProducts that will return the products if they are already in the products object. If they aren’t, it proceeds to call the db function and update our empty products object with the data from the db.json file.

index.html


let products = {};
// Initializing an empty object for us to cache our products in

  // "Database" of products
    const db = async () => {
      // This has to be async because we're using fetch
      const response = await fetch("/db.json")
      // Get the db.json file
      const data = await response.json()
      // Parse the JSON
      return data
      // Return the data
    }

    const getProducts = async () => {
      // This has to be async because we're using fetch
      if (products.length > 0) {
        return products
        // If we've already fetched the products, return them
      }
      const data = await db()
      // Get the data from the db
      products = data.products
      // Set the empty object to the products in the db
      return products
      // Return the products
    }

Nested and Dynamic Routes

So, we have our products, but we want to be able to create individual pages for each product so that people can share links to them. Also, we want to be able to share our data loading between these pages to avoid unnecessary server requests. We can do this by using nested and dynamic routes.

Generally, this would be achieved by sharing the same pathname for all of the pages that you want to share data between. For example, if you wanted to share data between the /products and /products/:id pages, you would use the same pathname for both of them. This is called a nested route. But, this example is also a dynamic route in that it uses a wildcard or a specific character like a colon to indicate a dynamic segment of the pathname— id in this example.

Most routers employ regex to find this character and then use it to match the pathname to the route. This is a very powerful tool, but it can also be a bit confusing. I made a decision to not use any regex in this project. Instead, I will simply be designating one individual route as the dynamic route and then using the pathname to determine how to render the component. Frankly, I think this is a lot easier to understand and maintain.

However, this method is very inexact. Despite my best efforts, I could not get the product and products page to share a route. I was able to get it to work for most situations, but I had enough edge cases that I decided to just use two separate routes. This is a bit more verbose, but it is also a lot more explicit and error-free. I may come back to this project in the future and try to make a more perfect example, but this works well. There are more examples in the footnotes if you want to see how others have done it.

New Router and Routes

In our last chapter, our Routes configuration object handled all of the rendering logic. Also, our Router function was not capable of handling dynamic routes. Let’s see one way of changing them to accomodate these new features.

index.html

const Routes = [
      { path: "/", component: Home },
      { path: "/about", component: About },
      { path: "/products", component: ProductsPage },
      { path: "/product", component: ProductPage },
      { path: "/cart", component: Cart },
    ]

    const Router = (potentialRoute) => {
    const dynamicRoute = "product" // Define a dynamic route
      if (
        // We're turning the path into an array by splitting it every time we see a slash.
        potentialRoute.split("/")[1] === dynamicRoute &&
        // Then we're checking to see if the second item is our dynamic route.
        potentialRoute.split("/")[2]
        // Finally, we're making sure there is an id in the third item.
      ) {
        const id = potentialRoute.split("/")[2]
        // If there is a third item, we're setting it to the id variable.
        return ProductPage(id)
        // Then we're calling the ProductPage function and passing it the id.
      } else {
        const route = Routes.find(
          (route) => route.path === potentialRoute
          // If it's not a dynamic route, normal logic applies.
        )
        route ? route.component() : Nope()
        // Call 404 function if no route is found.
      }
    }

If you wanted to have multiple dynamic routes, we could define them as an array. Then, we could create a separate part of our Routes object that would hold the processing logic for each dynamic route. I thought about doing this for the project, but I honestly couldn’t think of a good use case for it in an e-commerce app unless I added more features like Auth. So, I decided to keep it simple.

Conclusion and Demo

So, we have fully working dynamic routes— even though it could be done in a more elegant way. I’m not particularly happy with my implementation, but it works. We’re getting there, but the buttons don’t work yet. Let’s see it in action.

Other Ways to Do This

Like I said, I’m not particularly happy with my implementation. So, if you would like to see some other ways to do it, check out the following links. Some of these served as inspiration for my implementation. However, I didn’t like the way that many of them employed an object-oriented approach or used regex.

I’m more of a functional programmer, so I wanted to keep it that way. After completing this tutorial, I think there is definite merit both in the object-oriented approach and using regex for parsing the pathname. So, I can’t blame you if you decide to use one of these other approaches. Something like hash routing is generally outdated, but it is still a viable option.

Links!

1: Will Taylor’s blog article was my biggest inspiration. I recommend you start here.

2: DCode Software’s article is a great example of using regex

3: Here’s a great example of how to use hash routing by The Dev Drawer.

4: Vijit Ali did a write-up of one method.

5: Daleighan seems to have put a lot of thought into this.

6: Jeff Delaney released Flamethrower which has some features including pre-fetching

7: And, finally if you really want features without a whole framework, you can bring in a library like Turbo from Hotwired

Table of Contents Comments View Source Code Next Page!