Build modular app with Alpine.js

Recently I've created a POC involving some new frontend technologies and Alpine JS was one of them among others. In this article I will show an approach to create modular web apps with Alpine.

Context

Our context is create medium/large size web application totally modular. Each page is treated as module composed by many components and in the backend side we have Go processing the page creation like SSR.

Alpine

AlpineJS is a new kids on the block on Javascript land and Its describe in their site as:

Your new, lightweight, Javascript framework

AlpineJS is very simple and easy to use. It has 3 pillars: Attributes, Properties and Methods. My goal is not to introduce Alpine, but show our strategy to modulize the application using Alpine.

Page and Components

A page is composed by many components, navbar, cards, box, menu, fields, graphs etc. In Alpine a component can be a simple div with x-data attribute, simple ha!? To reuse component's logic we decide to create a single JS file that represents logic and state of each component. Let's see a simple example of a file with counter.

export function counter() {
    return {
    count: 0,

        reset() {
            this.count = 0;
        },

        increment() {
            this.count++;
        },

        decrement() {
            this.count--;
        }
    }
}

In the example above we have created a counter component with count attribute and 3 operations: reset, increment and decrement. In HTML side we need to attach its function with our component, like:

<div x-data="counter" class="box-counter">
        <span class="lbl-counter" 
            :class="{'lbl-counter-red': count < 0, 'lbl-counter-blue': count > 0}"
            x-text="count">0</span>
        <div class="">
            <button type="button" class="btn-counter" @click="increment"> Increment </button>
            <button type="button" class="btn-counter" @click="reset">Reset</button>
            <button type="button" class="btn-counter" @click="decrement"> Decrement </button>
        </div>
    </div>

As you can see, our div tag has an attribute x-data that has value counter. So Alpine does the magic here linking both (HTML and Javascript).

Very simple and scalable to create components like that. But let's imagine a page with 20 or 30 components like it, I think we will have a messy page and very hard to maintain.

Let's break down our problem into 2 parts: script composition and loading.

Script Composition

The structure of application is based on pages and each page has an index.ts that will exports all components necessary to that page. On image below you can see POC structure:

According to the image, we have 4 pages: demo, home, login and prospects. We created a folder shared that contains all shared components between the pages, like: menu, navbar, etc. Let's explore the demo page.

The demo page is composed by 3 components: menu, counter and todos. The index.ts file for this page is shown below:

import menu from '../shared/menu'
import counter from './counter'
import todos from './todos'

export {
    menu,
    counter,
    todos
}

The demo HTML page has 3 HTML elements referring to those components, let's see the snippet of the HTML page:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Demo</title>
    <link rel="stylesheet" href="assets/global.css" />
</head>

<body>
    <nav x-data="menu" class="nav-header">
      ...
    </nav>

    <div x-data="counter" class="box-counter">
      ...
    </div>

    <div x-data="todos" class="todoapp">
      ...
    </div>
</body>
</html>

Using this strategy we can build very sophisticated pages in a modular manner very easily. One problem was resolved, so we need to nail down the second one.

Script Loading

Script loading is very important issue to reduce boilerplate code. We have created a loader function that solve it for us. The loader function is shown below:

export async function loader(modules) {
    const { default: alpinejs } = await import('https://cdn.skypack.dev/alpinejs')
    let promises = modules.map((mod) => import(mod))
    return Promise.all(promises).then(values => {
        console.debug('Alpine', alpinejs.version)
        values.forEach(module => {
            Object.keys(module).forEach(attr => {
                let data = module[attr]();
                alpinejs.data(attr, () => data);
            })
        })
        alpinejs.start();
    })
}

It is a naive example that loads Alpine's runtime dynamically from CDN and loads all modules passed by the argument and registers them into Alpine as components.

Now we just use it in our HTML page to load each page module.

<script defer type="module">
    import { loader } from './assets/loader.js'
    loader(['/dist/demo/index.js']).catch(err => console.error(err))
</script>

As you can see we put our compiled Javascript file inside /dist/demo/index.js. Its a standard we decided to our application and works fine for us. We are using rollup to transpile our Typescript code and bundle it.

Summarize

Alpine is a great player for us and its simplicity helps us to be more productive.

I hope this article can help you and suggestions are very welcome!

44