Back to Table of Contents

Conclusion and Other Features

What CoPilot thinks a cat looks like

By Jesse Pence

Introduction

So, we are reaching the end of our journey. By using a new runtime and cutting-edge web APIs, we have updated our vanilla JavaScript Single Page Application to create a type-safe, immersive experience without requiring a framework or a build step. However, we also explored how we could use a meta-framework like Astro to transition this into a Multi-Page Application to maximize the benefits of both approaches (which will only grow more appealing as the View Transitions API becomes stable). I might be overselling it a bit, but I think we have come a long way.

While we have covered a lot of ground in these articles, there is still so much more to learn. I have a lot of ideas for future topics, but I want to hear from you. Let me know in the comments what I got wrong or what you want to see next!

Before I finish this series, I realized that I added several new features to the application over the course of writing these articles and I only barely mentioned them. I wanted to take this opportunity to go over them in more detail. I won’t be explaining everything about these concepts, but it felt like a disservice to not explain them at all.

Let’s take a look at the code. I’ll explain each feature after the demo. Hopefully, you can see how using JSDoc types encourages you to document your code as you write it.

New Features

Checkout.js

import render from "render"
import { cart } from "cart"

/**
 * Loads Stripe.
 * @returns {Promise<import("stripe-js").Stripe | null>} - The Stripe object.
 */
async function stripeLoader() {
  const { loadStripe } = await import("stripe-js")
  const pk =
    "pk_test_51Ls3YjJiFO7cOn9i5GWxoJdBk5iN6FnUgdaHgD2wBxN7bqVFfcMKXQI4v86fwqhxe4b8CjYOKZNjg2VrcU2yply200OxYQlFCt"
  const stripe = await loadStripe(pk)
  return stripe
}

/**
 * Creates the line items for the Stripe checkout session.
 * @returns {LineItem[]} - The line items for the Stripe checkout session.
 */
function createLineItems() {
  console.log("createLineItems", cart())
  return Object.keys(cart()).map((id) => {
    const idNum = Number(id)
    return {
      price_data: {
        currency: "usd",
        product_data: {
          name: cart()[idNum].product.title,
          images: [cart()[idNum].product.images[0]],
        },
        unit_amount: cart()[idNum].product.price * 100,
      },
      quantity: cart()[idNum].quantity,
    }
  })
}

/**
 * Creates a Stripe checkout session.
 */
async function createSession() {
  const button = document.querySelector("#checkout")
  if (!button || !(button instanceof HTMLButtonElement)) {
    console.error("No checkout button found")
    return
  }
  button.disabled = true
  button.innerText = "Redirecting to Stripe... Hold your horses!"
  console.log("line items", createLineItems())
  const response = await fetch("/create-checkout-session", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      line_items: createLineItems(),
    }),
  })
  const data = await response.json()

  console.log(data)

  const stripe = await stripeLoader()

  if (!stripe) {
    console.error("Stripe failed to load")
    return
  }

  const result = await stripe.redirectToCheckout({
    sessionId: data.session.id,
  })

  console.log(result)

  // location.href = data.session.url
}

/**
 * Attaches the event listener to the checkout button.
 */
function attachListener() {
  const checkoutButton = document.querySelector("#checkout")
  if (checkoutButton) {
    checkoutButton.addEventListener("click", createSession)
  }
}

/**
 * Collects the products in the cart and renders them to HTML.
 */
const renderCartItems = () => {
  console.log("renderCartItems", cart())
  const cartItems = Object.keys(cart()).map((id) => {
    const idNum = Number(id)
    return `
      <div class="product">
      <a href="/product/${cart()[idNum].product.id}">
        <img src="${cart()[idNum].product.images[0]}" />
        <h2>${cart()[idNum].product.title}</h3>
      </a>
        <span>$${cart()[idNum].product.price}</span>
        <span>Quantity: ${cart()[idNum].quantity}</span>
      </div>
    `
  })
  return cartItems.join("")
}

/**
 * The checkout page view template.
 */
export default function Checkout() {
  document.title = "Checkout"
  if (Object.keys(cart()).length === 0) {
    render({
      component: `
        <h1>Checkout</h1>
        <p>We want to sell you things, but you don't have anything in your cart. You should probably fix that.</p>
      `,
    })
    return
  }

  render({
    component: `
    <div>
      <h1>Checkout</h1>
      ${renderCartItems()}
      
        <p>Total: $${Object.keys(cart()).reduce(
          (acc, id) =>
            acc +
            cart()[Number(id)].product.price * cart()[Number(id)].quantity,
          0
        )}</p>
      
        <button 
        style="margin-bottom: 1rem;"
        id="checkout">Checkout (Redirect to Stripe)</button>
    </div>
`,
    callback: attachListener,
  })
}

Stripe

I needed to integrate some outside libraries to make the bundling chapter make more sense. Considering that we have been building a pretend e-commerce application for a store, integrating a payment provider like Stripe seemed like a good idea. I don’t recommend using this code in production as it has been simplified for the sake of demonstration, but this should give you a good idea of how we are not that far away from something viable.

Hilariously, Stripe loads around 100kb of unnecessary JavaScript onto the page— just to redirect you to a custom URL. To diminish the impact of this on the performance of our app, I’m using this as an opportunity to demonstrate dynamic imports. This allows us to only load the Stripe library when the customer actually clicks the “Redirect to Stripe” button. I saw that there is a different import mentioned the docs to avoid this, but it’s good to know how to do it yourself.

Funnily enough, it turns out that you don’t really need to load Stripe on the client side at all. When I was inspecting the response that I get from the server, I noticed that you can just pluck out the URL and redirect the customer yourself. Stripe probably doesn’t want people to know this so they can keep injecting their cookies at will. I left the dynamic import in the code for the sake of showing off the code-splitting, but you can uncomment the code in the Checkout page in the demo to see what I mean

Calendar

I had heard date libraries could be a contributor to bloated bundle size, and date-fns is no exception. Not only does it seem to add around 100kb to the unbundled page, but it makes over 100 requests for each individual script it uses— even though we’re only using three functions. Even with modern multiplexing, this is a lot of overhead. I was hoping tree shaking would help, but maybe it is actually using all of those scripts. I’m not sure.

I also brought in a calendar library called FullCalendar. It imports Preact and a few thousand lines of code as well, so the events page is definitely a great spot to check out the loading spinner. Between the two, we’re adding over 300kb of JavaScript to the unbundled page somehow. I’m not sure how much of that is actually being used, but this is pretty typical of the kind of bloat you can expect from most modern JavaScript libraries.

This is a great example of being wary of the dependencies you bring into your app. The Astro version of the app has a simple calendar I built with vanilla JavaScript that is only around 1kb. Most things can be achieved with a little hard work, and it is often worth it to avoid bringing in a dependency. Just look at it as an opportunity to learn something new!

Signals

Signals are a concept that have gained popularity in JavaScript recently. Although recently popularized with their use in SolidJS, the concept traces its roots to MobX, RxJS, and most notably Knockout. You can read more about the history here, but many frameworks have picked them up recently. Even Angular has joined the party.

Listen— I am no Ryan Carniato. This is an extremely simple implementation of signals. I combined Ryan’s article here with this one. This is not perfect, but it works for what I need— updating the header and the cart page when the user adds or removes an item.

I probably should have stuck closer to Ryan’s example. If you want to use signals, I definitely recommend just using SolidJS. It’s fantastic. I plan on covering it in depth in a future article. If you want to learn more for now, I recommend watching this talk.

Improved Cache

In the conclusion to the first series of articles, I explained the deficiencies in the cache as it stood. We fixed the error handling at the beginning of this series, but I never got around to explaining how I fixed the cache. It’s nothing crazy, but it fits most use cases honestly.

Previously, when the user first opened the app to a products page or used the search bar, we simply set a global variable and used it for the rest of the session. This was fine for the most part as we never expected our products to change. I wanted to show how you can simply set a timestamp in localStorage, and use it to invalidate the cache. If you expect the items to change often, but you still don’t want to make a request every time, this is an easy method for preventing stale data while limiting network activity.

Toast

I felt like I didn’t do enough to emphasize how the View Transitions API is not a replacement for all CSS animations. Really, it’s best suited for complete page transitions. Often, triggering a View Transition causes each element on the page to initiate their associated animations at the same time. While this is perfect when transitioning between pages, it is not ideal for small animations like an error or success message. So, I wanted to show how you can use regular CSS animations for this.

Previously, when a user added or removed an item from their cart, we just used the standard alert function to display a message. This is not ideal as it is not very customizable. So, we’re implementing what is called a “toast” notification. This is a small message that appears at the margins of the screen and then fades away after a few seconds. Many people reach to outside libraries to fill this need, but I wanted to show how you can do it with vanilla JavaScript. Only 100 lines of code!

Hamburger Menu

After adding all these awesome, new features, I realized that our website wasn’t really mobile responsive— despite me joking about flexbox doing all the work for us. The navigation links in our header were already pretty cluttered at the end of the last series. Now that we added the events page, it was getting a little out of hand.

It seemed like a shame to try to show off exciting new web API’s on a dirt ugly website. Anyone who looked at this thing on a mobile device would be immediately turned off. So, I decided to conditionally hide the header on mobile devices behind a hamburger button. You know— those three lines that you click to see the navigation links for a website. I didn’t go too crazy, but I figured this was a good opportunity to show off a simple media query. And, I added a cute, little CSS transition to make it slide up and down.

Conclusion

In both this series and the last, we have been building a single page application with vanilla JavaScript. One of my goals has been to show that a website doesn’t need to be built with a framework to be modern. JavaScript and Web API’s have come a long way in the last few years, and we can do a lot with just the browser.

However, this is not truly representative of the state of modern web development— for better or worse. Most new web apps are built with a framework like React, Vue, or Angular. These frameworks provide a lot of benefits, but they also come with a lot of baggage. They are complex, opinionated, and hard for beginners to understand.

In many cases, the code that you write is not the same as the code that is run. It is compiled and transformed into something else. When much of the difficulty is abstracted away, it can be harder to debug when things go wrong. If you don’t even understand how the framework actually works, how can you be expected to fix things when they break?

So, unless there are further requests for demonstrations of how to do things without a framework, these will be the final versions of our application written with vanilla JavaScript. Hopefully, you understand all the moving parts of the application that we have built. I want to leverage that understanding by porting this app to different JavaScript frameworks so you can see how they all accomplish the same goals.

There are still a lot of difficulties in building a single page application that I didn’t cover or only briefly mentioned. CSS scoping, accessibility, performance— there’s a lot I could talk about. Heck, I only briefly mentioned state management! I would be interested in doing a continuation, but I need direction. Let me know in the comments!

Barring a barrage of comments, I plan on focusing on UI frameworks from this point forwards. I will be starting with React, but I plan on covering literally all of the major frameworks. I already have versions written with SolidJS, Vue, Svelte, Qwik, and more. Stay tuned!

Table of Contents Comments View Source Code Other Articles!