Alpine.js: 7 best practices for staying in control

Alpine.js is designed to sprinkle a little interactivity into your project. That’s its main strength: quickly adding interactions without the complexity of a build system or a complete framework. But as soon as things get more complex, it’s often advisable to use another library or framework that will provide a better framework.

In my work, I chose to use AlpineJS for the redesign of a large website. After several months of development with an evolving number of features, it became increasingly difficult to maintain. I therefore had to find solutions to structure and document our approach to using AlpineJS on our project to avoid excessive technical debt.

Here are some tips I discovered as I used and improved our code base using AlpineJS.

1. Keep Alpine usage to a minimum in HTML

It’s tempting to use AlpineJS only in the HTML code of the site. However, the HTML code must remain readable. If you end up with x-* attributes on every element, that’s a red flag. The code becomes difficult to understand and prone to errors.

Bad practice:

<div x-data="{ count: 0, doubled: 0, tripled: 0 }"
     x-init="$watch('count', value => { doubled = value * 2; tripled = value * 3 })">
  <button @click="count++; doubled = count * 2; tripled = count * 3">
    Increment
  </button>
  <p x-text="'Count: ' + count + ', Doubled: ' + doubled + ', Tripled: ' + tripled"></p>
</div>

Good practice:

<div x-data="counter">
  <button @click="increment">Increment</button>
  <p x-text="display"></p>
</div>

<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('counter', () => ({
    count: 0,

    doubled() { return this.count * 2 },
    tripled() { return this.count * 3 },
    display() {
      return `Count: ${this.count}, Doubled: ${this.doubled()}, Tripled: ${this.tripled()}`
    },

    increment() { this.count++ }
  }))
})
</script>

HTML becomes declarative and readable. Logic is externalized into a reusable component. I strongly recommend externalizing logic into Alpine components, as this facilitates maintainability while preserving Alpine’s performance.

2. Do not use x-ref for dynamic elements

x-ref allows you to create a reference to a DOM element and access it from the Alpine component. However, if this element is added or removed dynamically, the reference becomes unstable.

Bad practice:

<div x-data="{ items: ['Item 1'], addItem() { this.items.push('Item ' + (this.items.length + 1)) } }">
  <button @click="addItem">Add Item</button>
  <template x-for="(item, index) in items" :key="index">
    <div x-ref="dynamicItem">
      <span x-text="item"></span>
      <button @click="$refs.dynamicItem.classList.add('highlight')">
        Highlight
      </button>
    </div>
  </template>
</div>

Here, $refs.dynamicItem will only point to a single element: the last one. It will not point to all elements, as one might expect.

Good practice:

<div x-data="itemsList">
  <button @click="addItem">Add Item</button>
  <template x-for="(item, index) in items" :key="index">
    <div :class="item.highlighted ? 'highlight' : ''">
      <span x-text="item.name"></span>
      <button @click="toggleHighlight(index)">
        Highlight
      </button>
    </div>
  </template>
</div>

<script>
Alpine.data('itemsList', () => ({
  items: [{ name: 'Item 1', highlighted: false }],

  addItem() {
    this.items.push({
      name: `Item ${this.items.length + 1}`,
      highlighted: false
    })
  },

  toggleHighlight(index) {
    this.items[index].highlighted = !this.items[index].highlighted
  }
}))
</script>

Use reactive state rather than direct DOM manipulation. However, if you are certain that the element is unique, then the x-ref makes sense. Otherwise, avoid it. Furthermore, the reference is not accessible from the component’s parents.

3. Decouple more important features with x-data

When a feature starts to have more than 3-4 methods or states, move it into a reusable Alpine component.

Bad practice:

<div x-data="{
  query: '',
  results: [],
  loading: false,
  error: null,
  async search() {
    this.loading = true;
    this.error = null;
    try {
      const res = await fetch('/api/search?q=' + this.query);
      this.results = await res.json();
    } catch(e) {
      this.error = e.message;
    } finally {
      this.loading = false;
    }
  }
}">
  <!-- Template -->
</div>

Good practice:

<div x-data="searchComponent">
  <!-- Template -->
</div>

<script>
Alpine.data('searchComponent', () => ({
  query: '',
  results: [],
  loading: false,
  error: null,

  async search() {
    this.loading = true;
    this.error = null;
    try {
      const res = await fetch('/api/search?q=' + this.query);
      this.results = await res.json();
    } catch(e) {
      this.error = e.message;
    } finally {
      this.loading = false;
    }
  }
}))
</script>

In terms of syntax, there is no change; we are simply moving the code to an Alpine component. This reduces the risk of errors and allows for syntax highlighting. The module can be easily reused, and the logic is separated from the HTML.

4. With x-data, only use this for “public” variables.

A convention I’m starting to implement: private properties/methods that are only accessible from JS are prefixed with an underscore. Others that are not prefixed are used in HTML or JS. This makes it possible to know whether a function can be refactored directly or whether it is also necessary to check the templates.

We were fooled for a long time into not using this trick, but when using Alpine components with multiple methods, it’s unclear whether we created a property to access it from the template, or if it just allows us to store the state between the component’s methods.

Bad practice:

<div x-data="{
  data: [],
  errors: [],
  fetchData() {
    this.validateToken()
    this.makeRequest()
    this.processResponse()
  },
  validateToken() { /* ... */ },
  makeRequest() { /* ... */ },
  processResponse() { /* ... */ }
}">
  <button @click="fetchData()">Load</button>
</div>

Problem: All internal methods are exposed in the Alpine scope and accessible from HTML. If you want to remove properties or methods, you have to check everywhere to make sure nothing is using them.

Good practice:

<div x-data="{
  _data: [],
  _errors: [],
  fetchData() {
    this._validateToken()
    this._makeRequest()
    this._processResponse()
  },
  _validateToken() { /* ... */ },
  _makeRequest() { /* ... */ },
  _processResponse() { /* ... */ }
}">
  <button @click="fetchData()">Load</button>
</div>

In the example, only fetchData is used publicly, so we can modify the internal composition of the methods without creating side effects in other parts of the code.

5. Use HTML only for declarative elements; do not include imperative code or JavaScript.

HTML should describe what should be displayed, not how to calculate it.

Bad practice:

<div x-data="{ price: 100, quantity: 2, discount: 10 }">
  <input type="number" x-model="quantity">

  <!-- Imperative logic in HTML -->
  <p x-text="'Total: €' + (price * quantity - (price * quantity * discount / 100))"></p>

  <button @click="
    if (quantity > 10) {
      discount = 20;
      alert('Discount upgraded to 20%!');
    } else {
      discount = 10;
    }
  ">
    Calculate
  </button>
</div>

Good practice:

<div x-data="priceCalculator">
  <input type="number" x-model="quantity">

  <p x-text="totalDisplay"></p>

  <button @click="applyVolumeDiscount">
    Calculate
  </button>
</div>

<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('priceCalculator', () => ({
    quantity: 2,

    _price: 100,
    _discount: 10,

    _subtotal() {
      return this._price * this.quantity
    },

    _discountAmount() {
      return this._subtotal() * (this._discount / 100)
    },

    _total() {
      return this._subtotal() - this._discountAmount()
    },

    totalDisplay() {
      return `Total: €${this._total().toFixed(2)}`
    },

    // Méthode avec logique claire
    applyVolumeDiscount() {
      const newDiscount = this.quantity > 10 ? 20 : 10

      if (newDiscount !== this._discount) {
        this._discount = newDiscount
        this._notifyDiscountChange()
      }
    },

    _notifyDiscountChange() {
      // Isolated notification logic
      alert(`Discount: ${this._discount}%`)
    }
  }))
});
</script>

A few additional tips:

  • x-text="propertyName” instead of x-text="'Text ' + prop1 + prop2"
  • @click="methodName()" instead of @click="var1++; var2 = var1 * 2"
  • x-show="isVisible" instead of x-show="status === 'active' && user.role === 'admin'"

6. Keep x-data with as few children as possible

During initialization, Alpine recursively traverses all children of an x-data to detect directives (x-show, @click, etc.). The deeper the tree structure, the slower the initialization is likely to be.

When we started using AlpineJS, we put x-data on the body to share properties between different sections of the page, but this made the code complicated because it was not very isolated and had an impact on performance. Since then, I have started refactoring the code to use x-data in the right place with a store to manage and share the global state between components.

Bad practice:

<body x-data="godComponent()">
  <!-- many other elements of the DOM -->
  <div class="hero"></div>
  <div class="main"></div>
  <div class="newsletter"></div>
</body>

Problem: Alpine will go through all the children of the body, divs, spans, labels, etc., even if they have no directives. In addition, godComponent manages everything, and it is becoming increasingly difficult to modify this component without breaking part of the site.

Good practice:

<body>
  <div class="hero" x-data="header"></div>
  <div class="main"></div>
  <div class="newsletter" x-data="newsletter"></div>
</body>

Try to place x-data as close as possible to the elements that need it. Use Alpine.store() to share states between multiple x-data on the page.

7. Alpine.bind() to refactor forms and validations

When you have multiple fields with the same logic (validation, formatting, etc.), Alpine.bind() allows you to reuse behaviors without duplicating code.

Bad practice:

<div x-data="form">
  <input
    type="email"
    x-model="email"
    @blur="validateEmail"
    @input.debounce="checkEmailAvailable"
    :class="emailError ? 'border-red-500' : 'border-gray-300'"
    x-ref="emailInput">
  <span x-show="emailError" x-text="emailError"></span>

  <input
    type="email"
    x-model="alternateEmail"
    @blur="validateAlternateEmail"
    @input.debounce="checkAlternateEmailAvailable"
    :class="alternateEmailError ? 'border-red-500' : 'border-gray-300'"
    x-ref="alternateEmailInput">
  <span x-show="alternateEmailError" x-text="alternateEmailError"></span>

  <!-- The same applies to 5 other fields... -->
</div>

Good practice:

<div x-data="form">
  <div>
    <input type="email" x-bind="emailField('email')">
    <span x-show="$store.form.errors.email" x-text="$store.form.errors.email"></span>
  </div>

  <div>
    <input type="email" x-bind="emailField('alternateEmail')">
    <span x-show="$store.form.errors.alternateEmail" x-text="$store.form.errors.alternateEmail"></span>
  </div>

  <!-- The same applies to 5 other fields... -->
</div>

<script>
document.addEventListener('alpine:init', () => {

  Alpine.bind('emailField', (fieldName) => ({
    'x-model': fieldName,
    '@blur'() {
      this.validateEmail(fieldName)
    },
    '@input.debounce.500ms'() {
      this.checkEmailAvailable(fieldName)
    },
    ':class'() {
      return this.$store.form.errors[fieldName] ? 'border-red-500' : 'border-gray-300'
    }
    // Méthodes de validation
    validateEmail(fieldName) {
      // ...
    },

    async checkEmailAvailable(fieldName) {
      // ...
    },
  }));

  Alpine.data('form', () => ({
    // Form state
  }));

  Alpine.store('form', () => ({
    // Form errors storage
  }));
});
</script>

Conclusion

Alpine.js excels in its niche: lightweight interactions without complex tools. As the scope of the application grew, we kept Alpine and implemented rules to organize and structure how we work with Alpine. If this isn’t done, it’s very easy to slip into an application that is difficult to maintain. We ultimately use a Typescript setup with Alpine, with each component in its own file. So we use something very close to a framework, but which gives us the freedom to organize ourselves as we wish with the performance of a fast and lightweight library.

Feel free to comment on how you use Alpine, as well as any tips and advice you have discovered while using it.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *