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.