Alpine.js

Use Alpine as the exception layer for latency-sensitive islands.

Alpine is the exception layer in GO(A)HT. Start with page HTML, partial HTML, and SSE-driven refreshes. Add Alpine only when a small part of the screen needs local state that cannot wait for a server round-trip.

Decision Boundary

Use Alpine when:

  • the UI must respond before the server confirms the action
  • one screen section has several pieces of local state that must stay in sync
  • a small island needs optimistic updates
  • the interaction is awkward to model as repeated HTML swaps

Do not use Alpine when:

  • HTMX can fetch and swap the needed HTML fragment
  • the interaction is mostly forms, navigation, or CRUD
  • you only need show/hide behavior or a one-off click handler
  • you are about to create JSON endpoints for markup that could stay server-rendered

If you can solve it with HTML fragments, do that first.

The Island Pattern

Keep Alpine small and local. The page still belongs to the server.

<section>
  {{ template "partials/scoreboard_shell.html" . }}

  <div x-data="gameStore({{ .Game.ID }})" x-init="init()">
    <p class="sr-only" x-text="status"></p>

    <button @click="recordThrow('home', 'hole')" :disabled="loading">
      IN +3
    </button>

    <span x-text="state.home.score">0</span>
  </div>
</section>

That is the intended shape: server-rendered page, one Alpine island for the latency-sensitive interaction.

JSON Is For Islands, Not For The Whole App

An Alpine island usually needs a narrow JSON surface:

func (h *APIHandlers) GameState(w http.ResponseWriter, r *http.Request) {
    state, err := h.games.StateJSON(r.Context(), r.PathValue("id"))
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    writeJSON(w, state)
}

Keep those endpoints specific:

  • one island
  • one state shape
  • one mutation path

Do not turn the app into a general client-side JSON consumer just because one island needs optimistic updates.

A Minimal Alpine Store

<script>
function gameStore(gameID) {
  return {
    gameID,
    loading: false,
    status: "",
    state: {
      home: { score: 0, bagsUsed: 0 },
      away: { score: 0, bagsUsed: 0 },
    },

    async init() {
      await this.fetchState()
      this.connectEvents()
    },

    async fetchState() {
      const res = await fetch(`/api/games/${this.gameID}/state`)
      if (res.ok) this.state = await res.json()
    },

    async recordThrow(team, result) {
      this.loading = true

      const snapshot = structuredClone(this.state)
      this.applyOptimisticThrow(team, result)

      try {
        const res = await fetch(`/api/games/${this.gameID}/throws`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ team, result }),
        })
        this.state = await res.json()
      } catch (_) {
        this.state = snapshot
      } finally {
        this.loading = false
      }
    },

    applyOptimisticThrow(team, result) {
      if (result === 'hole') this.state[team].score += 3
      if (result === 'board') this.state[team].score += 1
      this.state[team].bagsUsed += 1
    },

    connectEvents() {
      const es = new EventSource(`/games/${this.gameID}/events`)
      es.addEventListener('state', (e) => {
        if (!this.loading) this.state = JSON.parse(e.data)
      })
    },
  }
}
</script>

The server remains the source of truth. Alpine provides a fast local cache and optimistic feel for one island.

Prefer Simpler Tools First

Before Alpine, check these options:

  • plain HTMX form submission with fragment replacement
  • HTMX polling or SSE-triggered fragment refresh
  • a small inline script for one-off toggles
  • CSS-only disclosure patterns where appropriate

That keeps Alpine reserved for the cases where it earns its cost.

BagTrax-Style Use Cases

Alpine fits interactions like:

  • rapid score entry where users feel latency immediately
  • touch-heavy controls with optimistic feedback
  • local disambiguation state before a final server write

It is usually the wrong tool for:

  • dashboards that mostly re-render server HTML
  • settings forms
  • list filtering that can be handled as a server round-trip
  • status panels updated by SSE-triggered HTMX refreshes

Gotchas

  • Do not mix HTMX swaps and Alpine ownership on the same DOM node.
  • Keep Alpine state local to the island it owns.
  • Reconcile with server truth after optimistic actions.
  • If the island grows until it owns most of the page, the boundary is wrong.