Alpine.js
When to use Alpine.js vs HTMX, and how to build reactive islands.
HTMX handles 90% of interactivity. Alpine.js handles the other 10% — the parts that need real client-side state: optimistic updates, complex forms, interactive scoring, local toggle state.
When to Use What
| Need | Tool |
|---|---|
| Navigation, page transitions | HTMX (hx-boost) |
| Load data into a section | HTMX (hx-get, hx-swap) |
| Form submission | HTMX (hx-post) |
| Real-time updates from server | HTMX SSE (sse-connect) |
| Optimistic UI updates | Alpine.js |
| Complex local state (game scoring) | Alpine.js |
| Form validation before submit | Alpine.js |
| Toggle/accordion/dropdown | Alpine.js (or just CSS) |
The Reactive Island Pattern
Pages use HTMX by default. When a section needs rich client interactivity, wrap it in an Alpine x-data component — a “reactive island” in a sea of server-rendered HTML.
<!-- The page is HTMX-driven -->
<div hx-boost="true">
{{ template "nav" . }}
<!-- This island is Alpine-driven -->
<div x-data="gameStore({{ .GameID }})" x-init="init()">
<span x-text="state.team1.score">0</span>
<button @click="throwBag(1, 'hole')">IN +3</button>
</div>
</div>
Alpine Component Pattern
Define components as plain functions that return an object:
<script>
function gameStore(gameId) {
return {
gameId: gameId,
loading: false,
state: {
team1: { score: 0, bags_used: 0 },
team2: { score: 0, bags_used: 0 },
current_round: { team1_points: 0, team2_points: 0 },
},
async init() {
await this.fetchState();
this.connectSSE();
},
async fetchState() {
const res = await fetch('/api/game/' + this.gameId + '/state');
if (res.ok) this.mergeState(await res.json());
},
mergeState(newState) {
Object.assign(this.state, newState);
},
// Optimistic update + server confirm
async throwBag(teamId, throwType) {
const team = teamId === 1 ? this.state.team1 : this.state.team2;
if (team.bags_used >= 4) return;
// Optimistic: update immediately
team.bags_used++;
if (throwType === 'hole') team.bags_in++;
// Confirm with server
try {
const res = await fetch('/api/game/' + this.gameId + '/throw', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'team_id=' + teamId + '&throw_type=' + throwType,
});
this.mergeState(await res.json());
} catch (e) {
await this.fetchState(); // Rollback on error
}
},
connectSSE() {
const es = new EventSource('/game/' + this.gameId + '/events');
es.addEventListener('state', (e) => {
if (!this.loading) this.mergeState(JSON.parse(e.data));
});
es.addEventListener('refresh', () => this.fetchState());
es.onerror = () => setTimeout(() => this.connectSSE(), 2000);
}
};
}
</script>
JSON API Endpoints
Alpine components talk to dedicated JSON API endpoints — separate from your HTMX HTML endpoints:
// GET /api/game/{id}/state — returns JSON for Alpine
func (h *GameHandler) APIGetGameState(w http.ResponseWriter, r *http.Request) {
state, err := h.gameSvc.GetGameState(ctx, gameID)
if err != nil {
writeJSONError(w, "Game not found", 404)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stateToJSON(&state))
}
// POST /api/game/{id}/throw — accepts form data, returns JSON
func (h *GameHandler) APIRecordThrow(w http.ResponseWriter, r *http.Request) {
// Parse, validate, execute, return updated state as JSON
}
Optimistic Updates
The key pattern: update the UI immediately, then confirm with the server.
async throwBag(teamId, throwType) {
// 1. Optimistic update
team.bags_used++;
// 2. Haptic feedback (mobile)
if (navigator.vibrate) navigator.vibrate([30]);
// 3. Server request
try {
const data = await postAndParse('/api/game/' + this.gameId + '/throw', body);
this.mergeState(data); // Server is source of truth
} catch (e) {
await this.fetchState(); // Full rollback
}
}
This gives instant feedback while keeping the server as the source of truth. SSE broadcasts the confirmed state to all connected clients.
Gotchas
- Don’t mix HTMX and Alpine on the same element. Pick one per component boundary.
- Alpine
x-datacreates a scope. Nestedx-dataelements create child scopes. - Always include
<script defer>for Alpine. It needs to initialize after the DOM is ready. - Keep Alpine components in
<script>tags at the bottom of the template, not in external files. This keeps them co-located with the HTML they control.