Alpine.js : 7 bonnes pratiques pour garder le contrôle

Alpine.js est fait pour saupoudrer son projet d’un peu d’interactivité. C’est sa force principale : ajouter rapidement des interactions sans la complexité d’un build system ou d’un framework complet. Mais dès que l’on monte en complexité, il est souvent conseillé d’utiliser une autre librairie ou framework qui donnera un meilleur cadre.

Dans mon travail, j’ai fait le choix de partir avec AlpineJS pour la refonte d’un gros site. Après plusieurs mois de développement avec le nombre de fonctionnalités qui évolue, ce fût de plus en plus dur à maintenir. Il a donc fallu que je trouve des solutions pour structurer et documenter notre approche de l’utilisation d’AlpineJS sur notre projet pour éviter une trop grosse dette technique.

Voici quelques conseils que j’ai découvert au fur et à mesure de l’utilisation et des améliorations que j’ai fait sur notre base de code utilisant AlpineJS.

1. Rester minimal dans l’utilisation d’Alpine dans le HTML

C’est vite tentant d’utiliser AlpineJS seulement dans le code HTML du site. Cependant, le code HTML doit rester lisible. Si vous vous retrouvez avec des attributs x-* sur chaque élément, c’est un signal d’alarme. Le code devient compliqué à comprendre, et prône aux erreurs.

Mauvaise pratique :

<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>

Bonne pratique :

<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>

Le HTML devient déclaratif et lisible. La logique est externalisée dans un composant réutilisable. Je vous conseille fortement d’externaliser la logique dans des composants Alpine, car cela facilite la maintenabilité, tout en gardant les performances de Alpine.

2. Ne pas utiliser x-ref pour des éléments dynamiques

x-ref permet de créer une référence vers un élément DOM et d’y accéder depuis le composant Alpine. Mais si cet élément est ajouté ou supprimé dynamiquement, la référence devient instable.

Mauvaise pratique :

<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>

Ici $refs.dynamicItem ne pointera que vers un seul élément : le dernier. Et non pas tous les éléments, comme on pourrait s’attendre.

Bonne pratique :

<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>

Utilisez l’état réactif plutôt que la manipulation DOM directe. Cependant, si on est certains que l’élément est unique, alors le x-ref fait sens. Sinon à éviter. De plus la référence n’est pas accessible depuis les parents du composant.

3. Découpler les fonctionnalités plus importantes avec x-data

Quand une fonctionnalité commence à avoir plus de 3-4 méthodes ou états, sortez-la dans un composant Alpine réutilisable.

Mauvaise pratique :

<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>

Bonne pratique :

<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>

En terme de syntaxe, il n’y a pas de changement, on déplace seulement le code vers un composant Alpine. De cette manière, on risque moins de faire d’erreur, on peut avoir de la coloration syntaxique. Le module peut être réutilisé facilement et on sépare la logique du HTML.

4. Avec x-data, n’utiliser `this` que pour les variables « publiques »

Une convention que je commence à mettre en place : les propriétés/méthodes privées qui ne sont accessibles que depuis le JS sont préfixées d’un underscore. Les autres qui ne sont pas préfixés sont utilisées dans le HTML ou JS. Cela permet de savoir si on peut réfactoriser une fonction directement ou s’il faut vérifier aussi dans les templates.

On s’est longtemps fait avoir à ne pas utiliser cette astuce, cependant, lorsqu’on utilise les composant Alpine avec plusieurs méthodes, on ne sait plus si on a créé une propriété pour y accéder depuis le template, ou si elle permet juste de stocker l’état entre les méthodes du composant.

Mauvaise pratique :

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

Problème : Toutes les méthodes internes sont exposées dans le scope Alpine et accessibles depuis le HTML. Si on veut supprimer des propriété ou méthodes, il faut vérifier partout que rien ne l’utilise.

Bonne pratique :

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

Dans l’exemple, seulement fetchData est utilisé publiquement, donc on peut modifier la composition interne des méthodes sans que cela crée des effets de bords dans d’autres parties du code.

5. Utiliser le HTML seulement pour le déclaratif, ne pas mettre de code impératif ou du JavaScript

Le HTML doit décrire ce qui doit être affiché, et non pas comment le calculer.

Mauvaise pratique :

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

  <!-- Logique impérative dans le 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>

Bonne pratique :

<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() {
      // Logique de notification isolée
      alert(`Discount: ${this._discount}%`)
    }
  }))
});
</script>

Quelques conseils supplémentaires :

  • `x-text= »propertyName »` au lieu de `x-text= »‘Text ‘ + prop1 + prop2″`
  • `@click= »methodName() »` au lieu de `@click= »var1++; var2 = var1 * 2″`
  • `x-show= »isVisible »` au lieu de `x-show= »status === ‘active’ && user.role === ‘admin’ »`

6. Garder les x-data avec le moins d’enfants possible

À l’initialisation, Alpine parcourt récursivement tous les enfants d’un x-data pour détecter les directives (x-show, @click, etc.). Plus l’arborescence est profonde, plus l’initialisation risque d’être lente.

Quand on a commencé à utiliser AlpineJS, nous avions mis des x-data sur le body pour partager les propriétés entre les différentes sections de la page, mais cela rendait le code compliqué car peu isolé en plus d’avoir un impact sur les performances. Depuis j’ai commencé un travail de refactoring afin d’utiliser les x-data au bon endroit avec un store pour gérer et partager l’état global entre composants.

Mauvaise pratique :

<body x-data="godComponent()">
  <!-- beaucoup d'autres elements du DOM -->
  <div class="hero"></div>
  <div class="main"></div>
  <div class="newsletter"></div>
</body>

Problème : Alpine va parcourir tous les enfants du body, les divs, les span, les labels, etc… même s’ils n’ont aucune directive. De plus, le godComponent gère tout, et c’est de plus en plus dur de modifier ce composant sans casser une partie du site.

Bonne pratique :

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

Essayer de placez x-data au plus proche des éléments qui en ont besoin. Utiliser Alpine.store() pour partager les états entre plusieurs x-data sur la page.

7. Alpine.bind() pour refactoriser les forms et validations

Quand vous avez plusieurs champs avec la même logique (validation, formatage, etc.), Alpine.bind() permet de réutiliser des comportements sans dupliquer le code.

Mauvaise pratique :

<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>

  <!-- Même chose pour 5 autres champs... -->
</div>

Bonne pratique :

<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>

  <!-- Même chose pour 5 autres champs... -->
</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', () => ({
    // État du formulaire
  }));

  Alpine.store('form', () => ({
    // stockage des erreurs du formulaire
  }));
});
</script>

Conclusion

Alpine.js brille dans son créneau : des interactions légères sans outillage complexe. Au fur et à mesure du grossissement du scope de l’application, nous avons gardé Alpine, et mis en place des règles pour organiser et structurer la façon de travailler avec Alpine. Si ce n’est pas fait, c’est très rapide de glisser vers une application peu maintenable. Nous utilisons au final un setup Typescript avec Alpine, chaque composant est dans son propre fichier. Nous utilisons donc quelque chose de très proche d’un framework, mais qui nous donne la liberté de nous organiser comme nous le souhaitons avec la performance d’une librairie rapide et légère.

N’hésitez pas à mettre en commentaire votre usage d’Alpine, ainsi que les astuces et conseil que vous avez trouvé en l’utilisant.


Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *