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.

When You DON’T Need Alpine

Not every project needs Alpine. If your app is mostly CRUD forms, navigation, and server-rendered content, HTMX + vanilla JS is enough.

ClawMachine (admin dashboard) removed Alpine entirely — all interactivity is HTMX forms + small vanilla <script> blocks for show/hide toggles. No reactive state needed.

Airmailed (real-time game) keeps Alpine because it needs:

  • Optimistic scoring updates (can’t wait for server roundtrip)
  • Complex local state (game store with 20+ reactive properties)
  • Touch gestures (swipe navigation, disambiguation modals)
  • SSE state merging with local state

Rule of thumb: If you can do it with hx-get + hx-swap, you don’t need Alpine. If you need to update the UI before the server responds, you do.

The Vanilla JS Alternative

For simple interactivity without Alpine:

<script>
  // Show/hide toggle
  document.querySelector('.toggle-btn')?.addEventListener('click', function() {
    document.getElementById('panel').classList.toggle('hidden');
  });
</script>

This is simpler than Alpine for one-off interactions. Use Alpine when you have multiple pieces of related state that need to stay in sync.

Rod vs Playwright for E2E Testing

The GOaT stack supports two E2E testing approaches:

Rod (Go)Playwright (JS/TS)
LanguageGo — same as your appTypeScript — separate toolchain
Best forGo-heavy teams, CI simplicityComplex UI assertions, visual testing
SpeedFast, single binaryFast, but needs Node.js
DebuggingGo debugger, rod.Show()Playwright Inspector, trace viewer
Setupgo get github.com/go-rod/rodnpx playwright install
Sandboxlauncher.New().NoSandbox(true) for containers--no-sandbox flag

Our recommendation: Rod for Go projects. It keeps your entire test suite in one language and one go test command. Use NoSandbox(true) when running in containers or CI.

func TestE2E_CreateBot(t *testing.T) {
    browser := rod.New().MustConnect()
    defer browser.MustClose()

    page := browser.MustPage("http://localhost:8080")
    page.MustElement("a[href='/bots/new']").MustClick()
    page.MustElement("input[name='name']").MustInput("test-bot")
    page.MustElement("button[type='submit']").MustClick()

    page.MustWaitStable()
    if !page.MustHas(".badge-success") {
        t.Error("expected success badge after bot creation")
    }
}

Best Practices (2026)

From real production experience with ClawMachine and Airmailed:

Forms

  • Native HTML forms over json-enc — handlers accept both form data and JSON via a parseRequest() helper
  • Always add loading indicators — inline spinners in submit buttons + global progress bar
  • hx-indicator for HTMX forms, JS btn.disabled = true for vanilla forms

Templates

  • Co-locate <script> with templates — don’t use external JS files for page-specific logic
  • template.FuncMap for shared helpers (add, sub, percent, deref, divFloat)
  • Partials for reusable componentstemplates/partials/*.html

Testing

  • Table-driven unit tests for pure logic (bracket generation, scoring permutations)
  • Integration tests with real DBtesting.Short() skip guard, testcontainers for CI
  • Handler tests with httptest — test validation, auth, error paths without DB
  • Rod E2E for critical user flows (bot install, game scoring)

State Management

  • Server is source of truth — Alpine state is a local cache, SSE pushes updates
  • Optimistic updates + rollback — update UI immediately, fetchState() on error
  • mergeState() over full replacement — avoid flickering by only updating changed fields

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.
  • DaisyUI stays — we tried ripping it out of ClawMachine once. Mobile nav broke across all sites. Don’t remove working CSS infrastructure without full mobile testing.
  • req.SetPathValue() is Go 1.22+ — use it in handler tests for path params.