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

NeedTool
Navigation, page transitionsHTMX (hx-boost)
Load data into a sectionHTMX (hx-get, hx-swap)
Form submissionHTMX (hx-post)
Real-time updates from serverHTMX SSE (sse-connect)
Optimistic UI updatesAlpine.js
Complex local state (game scoring)Alpine.js
Form validation before submitAlpine.js
Toggle/accordion/dropdownAlpine.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-data creates a scope. Nested x-data elements 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.