Reusable Alpine

Initialize, watch, and clean up

Use init and watchers for boundary synchronization without creating loops or forgotten external resources.

init() and $watch exist for one job: keeping Alpine state in sync with something outside Alpine. The URL, localStorage, a third-party widget, a browser event.

Anything inside Alpine should be derived, as we saw earlier. Watchers are for the boundary.

Let’s make the filter panel remember its state in the URL. Load /issues?status=open and the dropdown shows Open. Change the dropdown and the URL updates, so a reload or a shared link brings the same view back.

Read the URL in init

init() runs once, before the component renders. That’s where the outside world comes in:

Alpine.data('issueFilter', () => ({
  query: '',
  status: '',

  init() {
    const params = new URLSearchParams(location.search)
    this.query = params.get('query') ?? ''
    this.status = params.get('status') ?? ''
  }
}))

Load /issues?status=open&query=safari and both fields are filled before the user sees the page.

Write back with a watcher

$watch runs a callback whenever a property changes. Here we push the change into the URL without reloading:

init() {
  // ...read the URL as above

  this.$watch('status', () => this.syncUrl())
  this.$watch('query', () => this.syncUrl())
},

syncUrl() {
  const params = new URLSearchParams({ query: this.query, status: this.status })
  history.replaceState(null, '', `?${params}`)
}

Pick Closed in the dropdown and the address bar reads ?query=&status=closed. No request, no reload.

Keep watchers one-directional

The loop to avoid: a watcher on status writes the URL, and a watcher on the URL writes status. Each change triggers the other. Alpine skips updates when a value hasn’t changed, so it usually won’t loop forever, but you’ll get double updates and hard-to-trace flicker.

The fix is a direction. State flows to the URL through the watcher. The URL flows to state only in init() and on popstate, when the user presses Back. Never both ways from the same event.

Clean up what you attached

Listening to popstate means attaching a listener to window. Alpine doesn’t know about it. If this component lives inside an x-if and gets destroyed, the listener stays behind, pointing at a dead component.

destroy() is where you remove it:

init() {
  this.onPop = () => {
    const params = new URLSearchParams(location.search)
    this.status = params.get('status') ?? ''
  }
  window.addEventListener('popstate', this.onPop)
},

destroy() {
  window.removeEventListener('popstate', this.onPop)
}

Same for setInterval, IntersectionObserver, a map library, anything with a handle. If init() creates it, destroy() releases it.

Don’t watch derived values

One more thing I see often: $watch('title', () => this.remaining = 80 - this.title.length). That’s a getter pretending to be a watcher. It’s an extra copy of state with an extra way to drift. Use get remaining() instead.

Test the URL sync on your board: change the filter, press Back, press Forward. Watch the console for duplicate syncUrl calls, and check that toggling the component with x-if a few times leaves exactly one popstate listener in the Event Listeners panel.

Lesson completed