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,
  })
}

calendar.js

import { differenceInDays, formatISO, add } from "date-fns"
import { Calendar } from "@fullcalendar/core"
import dayGridPlugin from "@fullcalendar/daygrid"

/**
 * Creates a calendar using FullCalendar.
 */
export default function createCalendar() {
  const today = new Date()

  const events = [
    {
      name: "Murder Mystery Night",
      start: add(today, { days: 2, hours: 5 }),
      end: add(today, { days: 2, hours: 8 }),
    },
    {
      name: "Gorgonzola Enthusiasts Conference",
      start: add(today, { days: 5 }),
      end: add(today, { days: 7 }),
    },
    {
      name: "Dragon Appreciation Day",
      start: add(today, { days: 12 }),
      end: add(today, { days: 12 }),
    },
    {
      name: "Circus Skills Workshop",
      start: add(today, { days: 15 }),
      end: add(today, { days: 16 }),
    },
    {
      name: "Couch Sitting Competition",
      start: add(today, { days: 20 }),
      end: add(today, { days: 21 }),
    },
    {
      name: "Three Week Rustic Retreat",
      start: add(today, { days: 25 }),
      end: add(today, { days: 45 }),
    },
  ]

  const eventsList = document.getElementById("events-list")
  const calendarEl = document.getElementById("calendar")
  /**
   * @type {NodeListOf<HTMLDivElement>}
   */
  const calenderEvents = document.querySelectorAll(".fc-event")

  if (!calendarEl || !eventsList || !calenderEvents) return

  const calendar = new Calendar(calendarEl, {
    plugins: [dayGridPlugin],
    initialView: "dayGridMonth",
    events: events.map((event) => ({
      title: event.name,
      start: formatISO(event.start),
      end: formatISO(event.end),
    })),
  })

  calendar.render()

  calendarEl.style.backgroundColor = "black"
  calendarEl.style.padding = "1rem"
  calendarEl.style.opacity = "0.8"
  calendarEl.style.borderRadius = "0.5rem"
  calendarEl.style.margin = "1rem"

  calenderEvents.forEach((event) => {
    event.style.whiteSpace = "normal"
    event.style.fontSize = "1.5rem"
    event.style.textAlign = "center"
    event.style.padding = "0.5rem"
    event.style.borderRadius = "0.5rem"
  })

  eventsList.style.display = "grid"
  eventsList.style.gap = "1rem"
  eventsList.style.gridTemplateColumns = "repeat(auto-fit, minmax(200px, 1fr))"
  eventsList.style.maxWidth = "80vw"
  eventsList.style.margin = "1rem"
  events.forEach((event) => {
    const daysRemaining = differenceInDays(event.start, today)
    const listItem = document.createElement("li")
    listItem.textContent = `${event.name} is in ${daysRemaining} days`
    listItem.style.padding = "1rem"
    listItem.style.backgroundColor = daysRemaining < 7 ? "red" : "green"
    listItem.style.color = "white"
    listItem.style.borderRadius = "0.5rem"
    listItem.style.textAlign = "center"
    listItem.style.maxWidth = "200px"
    eventsList?.appendChild(listItem)
  })
}

store.js

import Nope from "Nope"

/**
 * The products array.
 * @type {Product[] | undefined}
 */
let products = []

/**
 * Fetches an object which contains the products array from the database.
 * @returns {Promise<RawData | Undefined>} The object which contains the products array.
 */

const db = async () => {
  try {
    const response = await fetch("https://dummyjson.com/products")

    if (!response.ok) {
      alert("HTTP-Error: " + response.status)
      console.error(response)
      Nope({ type: "badFetch", prop: response.status.toString() })
    }

    const data = await response.json()

    return data
  } catch (error) {
    console.error(error)
    Nope({ type: "badFetch", prop: error })
  }
}

/**
 * Fetches the products from the database.
 * @returns {Promise<Product[] | Undefined>} The products.
 */

export async function getProducts() {
  try {
    const cacheTimeLimit = 1000 * 60 * 10 // 10 minutes
    const now = Date.now()
    const cacheTimeStamp = localStorage.getItem("Cache Timestamp")
    // So, if the timestamp exists and is less than 10 minutes old, we'll use the cache
    if (cacheTimeStamp && now - Number(cacheTimeStamp) < cacheTimeLimit) {
      products = JSON.parse(localStorage.getItem("products") || "[]")
      console.trace("cache is still good, loading from cache")
      return products
    }
    console.trace("cache is empty or expired, loading from server")
    const data = await db()
    if (data) {
      products = data.products
      localStorage.setItem("products", JSON.stringify(products))
      localStorage.setItem("Cache Timestamp", now.toString())
      return products
    }
    Nope({ type: "badFetch", prop: "no data" })
  } catch (error) {
    console.error(error)
    Nope({ type: "badFetch", prop: error })
  }
}

/**
 * Fetches a product from the database.
 * @param {number} id - The id of the product to fetch.
 * @returns {Promise<Product | Undefined>} The product.
 */
export async function getProduct(id) {
  products = await getProducts()
  if (!products) {
    Nope({ type: "badFetch", prop: "no products" })
    return
  }
  return products.find((product) => product.id === Number(id))
}

export function createSpinner() {
  const spinner = document.createElement("div")
  spinner.classList.add("spinner")
  return spinner
}

render.js

/**
 * @type {(() => void)[] | undefined}
 */
let currentListeners = undefined

currentListeners = []

/**
 * Creates a reactive signal.
 * @param {any} initialValue - The initial value of the signal.
 * @returns {[() => any, (newValue: any) => void]} - A tuple containing the read and write functions.
 * @example
 * const [read, write] = createSignal(0)
 */
export function createSignal(initialValue) {
  let value = initialValue

  const subscribers = new Set()

  /**
   * Reads the current value of the signal.
   * @type {(() => any) | undefined}
   */
  const read = () => {
    if (currentListeners !== undefined) {
      currentListeners.forEach((fn) => subscribers.add(fn))
    }
    return value
  }

  /**
   * Writes a new value to the signal.
   * @type {(newValue: any) => void}
   * @param {any} newValue - The new value to set.
   * @returns {void}
   */
  const write = (newValue) => {
    if (value !== newValue) {
      value = newValue
      subscribers.forEach((fn) => fn())
    }
  }

  return [read, write]
}

/**
 * Used to run a callback function when a signal changes.
 * @param {() => void} callback - The callback function to run.
 * @returns {void}
 */
export function createEffect(callback) {
  if (!currentListeners) {
    throw new Error("createEffect must be called within a component")
  }

  currentListeners.push(callback)
  try {
    callback()
  } catch (e) {
    console.error("Error in effect", e)
  } finally {
    currentListeners.pop()
  }
}

/**
 * The options for the render function.
 * @typedef {object} RenderOptions
 * @property {string | (() => string)} component - The component to render.
 * @property {string} [element] - The element to render the component to.
 * @property {() => void} [callback] - A callback to run after the component has been rendered.
 */

/**
 * The render function-- renders the component to the active element.
 * @param {RenderOptions} options
 * @returns {void}
 * @example
 * render({
 * component: () => `<h1>Home</h1>`,
 * element: "#main",
 * callback: () => console.log("Home rendered"),
 * })
 */
export default function render({ component, element, callback }) {
  if (!element) element = ".main"
  const activeArray = document.querySelectorAll(element)
  if (!activeArray.length) {
    console.error(`No element found with selector ${element}`)
    return
  }

  addOldActiveClass()
  activeArray.forEach((activeElement) => {
    if (!activeElement) return

    transitionHelper({
      updateDOM: () =>
        createEffect(() => {
          activeElement.innerHTML = conditionalRender(component)
          activeElement.scrollTop = 0
          Array.isArray(callback)
            ? callback.forEach((fn) => fn())
            : callback && callback()
          addNewActiveClass()
        }),
    })
  })
}

/**
 * Helper function to handle view transitions
 * @param {object} options
 * @param {boolean} [options.skipTransition]
 * @param {string[]} [options.classNames]
 * @param {() => void} options.updateDOM
 * @returns {ViewTransition}
 */
export function transitionHelper({ skipTransition = false, updateDOM }) {
  if (skipTransition || !document.startViewTransition) {
    const updateCallbackDone = Promise.resolve(updateDOM())

    return {
      ready: Promise.reject(Error("View transitions unsupported")),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
    }
  }
  const transition = document.startViewTransition(updateDOM)

  return transition
}

/** @type {boolean} */
let clickedImage = false
/** @type {HTMLImageElement | null} */
let oldImage = null

document.addEventListener("click", (e) => {
  let element = e.target
  if (
    element instanceof HTMLImageElement ||
    element instanceof HTMLHeadingElement
  ) {
    if (element instanceof HTMLHeadingElement && element.parentNode) {
      element = element.parentNode.querySelector("img")
    }
    if (element instanceof HTMLImageElement) {
      oldImage = element
      clickedImage = true
    }
  } else {
    clickedImage = false
  }
})

/**
 * Adds the activeImage class to the old image.
 * @returns {void}
 */
function addOldActiveClass() {
  if (!clickedImage || !oldImage) return
  oldImage.style.viewTransitionName = "activeImage"
}

/**
 * Adds the activeImage class to the new image.
 * @returns {void}
 */
function addNewActiveClass() {
  if (!clickedImage) return
  const newImage = document.querySelector("img")
  if (!newImage) return

  newImage.classList.add("activeImage")
  newImage.style.viewTransitionName = "activeImage"
}

/**
 * Determines whether to render a string or a function.
 * @param {string | (() => string)} component
 * @returns {string}
 */
function conditionalRender(component) {
  if (typeof component === "string") {
    return component
  } else if (typeof component === "function") {
    return component()
  }
  throw new Error("Component must be a string or a function")
}

toast.js

/**
 * The array of toasts.
 * @type {toast[]}
 */
let toasts = []
let toastCount = 0

/**
 * The toast container.
 * @type {HTMLElement | null}
 */
const toastContainer = document.querySelector("#toast-container")

/**
 * The toast object.
 * @typedef {object} toast
 * @property {string} id - The id of the toast.
 * @property {string} message - The message to display in the toast.
 * @property {toastOptions} [options] - The options for the toast.
 */

/**
 * The options for the toast.
 * @typedef {object} toastOptions
 * @property {number} [duration] - The duration of the toast in milliseconds.
 * @property {string} [type] - The type of toast to display.
 */

/**
 * Adds a toast to the page.
 * @param {string} message - The message to display in the toast.
 * @param {toastOptions} [options] - The options for the toast.
 * @returns {string} - The id of the toast.
 */

export function addToast(message, options) {
  const id = `toast-${toastCount++}`
  const newToast = {
    id,
    message,
    options,
  }
  toasts.push(newToast)

  const duration = options?.duration ?? 3000

  setTimeout(() => {
    removeToast(id)
  }, duration)

  if (!toastContainer) return id

  toastContainer.appendChild(ToastComponent(newToast))

  return id
}

/**
 * Removes a toast from the page.
 * @param {string} id - The id of the toast to remove.
 * @returns {void}
 */
function removeToast(id) {
  const toast = document.querySelector(`#${id}`)
  if (!toast || !(toast instanceof HTMLElement)) return
  toast.classList.add("toast-out")
  toast.addEventListener("animationend", () => {
    toasts = toasts.filter((thisToast) => thisToast.id !== id)
    toast.remove()
  })
}

/**
 * The toast component.
 * @param {toast} toast - The toast object.
 * @returns {HTMLElement} - The toast element.
 */
function ToastComponent({ id, message, options }) {
  // Create a div and set the class, id, and aria attributes
  const toast = document.createElement("div")
  const type = options?.type
  toast.classList.add("toast")
  toast.id = id
  type && toast.classList.add(type)
  toast.setAttribute("role", "alert")
  toast.setAttribute("aria-live", "assertive")
  toast.setAttribute("aria-atomic", "true")

  // Set the innerHTML of the toast
  toast.innerHTML = `
    <div class="toast-header">
        ${type === "success" ? "Hooray!" : "Aww, shucks!"}
    </div>
    <div class="toast-body">
        ${message}
    </div>
    `

  // Create a close button, add the event listener, and add it to the toast
  const closeBtn = document.createElement("button")
  closeBtn.textContent = "X"
  closeBtn.addEventListener("click", () => {
    removeToast(id)
  })
  toast.appendChild(closeBtn)
  return toast
}

hamburger.js

export default function hamburger() {
  /**
   * Creates a hamburger menu for mobile devices.
   * @returns {void}
   */

  /**
   * The hamburger menu. You know, the three lines.
   * @type {HTMLElement | null}
   */
  const hamburgerMenu = document.querySelector("#hamburger")
  if (!hamburgerMenu) {
    throw new Error("Hamburger menu not found")
  }

  hamburgerMenu.addEventListener("click", () => {
    /**
     * The nav element as a whole.
     * @type {HTMLElement | null}
     */
    const nav = document.querySelector("nav")
    if (!nav) {
      throw new Error("Nav element not found")
    }
    /**
     * The nav ul element. This is the actual menu.
     * @type {HTMLElement | null}
     */
    const navUL = document.querySelector("nav ul")
    if (!navUL) {
      throw new Error("Nav ul element not found")
    }
    const screenH = window.innerHeight

    if (nav.style.display === "flex") {
      navUL.style.height = "0px"
      navUL.style.fontSize = "initial"
      setTimeout(() => {
        nav.style.display = "none"
      }, 480)
    } else {
      nav.style.display = "flex"
      setTimeout(() => {
        navUL.style.height = screenH + "px"
        navUL.style.fontSize = "2rem"
      })
    }

    globalThis.addEventListener("resize", () => {
      if (window.innerWidth > 768) {
        nav.style.display = "flex"
        navUL.style.height = "initial"
        navUL.style.fontSize = "initial"
      } else {
        nav.style.display = "none"
        navUL.style.height = "0px"
        navUL.style.fontSize = "initial"
      }
    })
  })
}

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!