Reusable Alpine

Extract Alpine.data components

Move a repeated or complex component definition out of markup while keeping its API small.

By now the issue editor’s x-data is a 20-line object with a getter, an async save() method, and error handling. Inline in an HTML attribute, that’s hard to read and impossible to test.

Alpine.data() lets you register that object once, under a name, in a JavaScript file. The HTML then says x-data="issueEditor" and nothing else.

Register the component

Alpine components must be registered before Alpine starts. The alpine:init event is the hook for that:

document.addEventListener('alpine:init', () => {
  Alpine.data('issueEditor', (issue) => ({
    title: issue.title,
    saving: false,
    error: '',

    get remaining() {
      return 80 - this.title.length
    },

    async save() {
      if (this.saving) return
      this.saving = true
      try {
        const res = await fetch(`/issues/${issue.id}`, {
          method: 'PUT',
          body: new FormData(this.$el)
        })
        if (!res.ok) throw new Error(await res.text())
      } catch (e) {
        this.error = e.message
      } finally {
        this.saving = false
      }
    }
  }))
})

Put this in a script that loads before Alpine’s CDN script, or in the same bundle if you use one. Inside the object, this is the reactive component, and magics like this.$el and this.$refs work as usual.

Use it in the HTML

The factory takes arguments. That’s how each editor gets its own issue:

<form x-data="issueEditor({ id: 42, title: 'Export CSV misses last row' })"
  @submit.prevent="save()">
  <input name="title" x-model="title">
  <span x-text="remaining"></span>
  <button :disabled="saving">Save</button>
  <p role="alert" x-show="error" x-text="error"></p>
</form>

Two forms on the page with different issues get two independent components. Change one title and the other’s remaining count doesn’t move. Try it: render two editors, type in one, and inspect both with Alpine.$data($0).

Pass only what it owns

Notice the argument is a small object with id and title. Not the whole issue list. Not the filter state. The editor owns editing one issue, so that’s all it receives.

When a component reaches out to state it wasn’t given, you’ve built a hidden dependency. Those are the ones that break when you move the component to another page.

Don’t extract everything

The other failure is extracting every two-line component. A disclosure with { expanded: false } reads better inline. Nobody wants to jump to components/disclosure.js to learn that a boolean flips.

My rule: extract when the object has a method, or when the same object appears twice. Otherwise leave it in the markup, where the behavior sits next to the element it controls.

init and destroy

Two method names are special. init() runs when the component starts. destroy() runs when Alpine removes it, for example when an x-if turns false. We’ll use both in the lesson on watchers and cleanup.

Take one component from your board that has a method and move it to Alpine.data. The HTML should get shorter and the JavaScript should become something you can read top to bottom.

Lesson completed