Authoring progressive enhanced fragments with Alpine

Authoring progressive enhanced fragments with Alpine

  • progressive-enhancement
  • performance
  • micro-frontends
  • scs
  • alpine
published 2020-10-07

Progressive enhancement is a content first strategy that separates presentation from content, providing an essential baseline / functionality to the majority of users while at the same time serving a fuller experience to browsers supporting x technical requirement.

In this post we’ll explore progressive enhanced behavior & templating over static content.

Back to our cart...

Let's continue to explore micro-frontends and SCS and its contract allowing multiple teams to share content with each other - fragments.

As mentioned in an earlier post, there are many viable options to choose from when authoring fragments, all with pros and cons depending on which architectural trade-offs you're willing to take. For the context of this post, the trade-off I'm willing to take, is a runtime dependency to Alpine.js.

Before diving into how we're going to build our cart fragment using progressive enhancement, let's quickly brush on what Alpine is.

Alpine.js

This is how the author of Alpine describes it:

Alpine.js offers you the reactive and declarative nature of big frameworks like Vue or React at a much lower cost. You get to keep your DOM, and sprinkle in behavior as you see fit. Think of it like Tailwind for JavaScript.

With its wide range of APIs for enhancing markup, Alpine will take you a pretty long way for very little effort as you see in this video of the cart fragment in action along with its markup / js further down.

Markup - cart fragment

Here is the markup for the cart fragment, everything is wrapped within an anchor tag that gets prevented when Alpine takes over.

<a
  href="/checkout"
  x-data="cart()"
  @click.prevent
  class="fragment"
  :class="[open ? 'fragment open' : 'fragment']"
>
  <div
    class="cart"
    x-on:buy-event.window="updateCart($event)"
    @click="toggleCart()"
    @click.away="open = false"
  >
    <div class="top">
      <div class="cartStatus" x-text="status"></div>
    </div>
 
    <div class="items">
      <div x-show.transition="open">
        <template x-if="items.length">
          <ul>
            <template x-for="item in items" :key="item">
              <li class="item">
                <span class="item-name" x-text="item.name"></span
                ><span class="item-price" x-text="`$${item.price}`"></span>
              </li>
            </template>
          </ul>
        </template>
        <button class="checkout">CHECKOUT</button>
      </div>
    </div>
  </div>
</a>

Without alpine, this markups serves an alternate version. Instead of buy buttons on the product page, we have direct links to each product page, and instead of the expanding minicart we have a link to the checkout page:

Markup - page (fragment consumer)

This could come from anywhere, Razor / EJS / Static Svelte you name it.

The important thing to note is that we´ve progressively enhanced our markup using unobtrusive syntax, that will get ignored by the browser if the CDN serving Alpine goes down or if JS is disabled. The active property is used to show / hide the enhanced version.

<div class="page" x-data="page()">
  <div class="products">
    <div
      class="product"
      @click="buyItem($dispatch, { name: 'Red ball', price: 200 })"
    >
      <div>Red ball</div>
      <div class="buy-btn" x-show="!active">DETAILS</div>
      <template x-if="active">
        <div class="buy-btn">BUY - $200</div>
      </template>
    </div>
    <!-- ... product 2 / 3 ... -->
  </div>
</div>

The buyItem method dispatches the buy event along with a payload describing the item that was clicked, this event is listened to by the cart fragment like so:

x-on:buy-event.window="updateCart($event)"

Behavior - Cart

Data object with properties and methods bound to the x-data="cart()" call in our cart component.

<script>
  function cart() {
    return {
      status: "CART IS EMPTY",
      items: [],
      open: false,
      toggle() {
        if (this.items.length) {
          this.open = !this.open;
        }
      },
      updateCart(event) {
        this.items.push(event.detail);
        this.total =
          this.items.length > 0
            ? "$" + this.items.reduce((i, { price }) => i + price, 0)
            : 0;
      },
    };
  }
</script>

Behavior - Page

Built in dispatch method extracted to send the buy-event

<script>
  function page() {
    return {
      active: true,
      buyItem(dispatch, item) {
        dispatch("buy-event", item);
      },
    };
  }
</script>

summary

I am amazed over how much you can do with a progressive enhancement library like Alpine, and even though the cart example is simple, from what I've seen I'm willing to bet that choosing Alpine for something more advanced still would be viable.

All in all - I give Alpine two thumbs up due to it being easy to reason about, that it has an overall better DX story over some of the other frontend libraries / frameworks out there and for that I see it being a good fit for a micro-frontend architectural baseline.

So with that being said, in light of its possibilities and ease-of-use, I feel Alpine.js is a pretty solid investment of your time should you want to get into the world of frontend alternatives.

Lastly, here's the link to the example repo if you're interested in checking out the code.

/ND