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.
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) | |
|---|---|---|
| Language | Go — same as your app | TypeScript — separate toolchain |
| Best for | Go-heavy teams, CI simplicity | Complex UI assertions, visual testing |
| Speed | Fast, single binary | Fast, but needs Node.js |
| Debugging | Go debugger, rod.Show() | Playwright Inspector, trace viewer |
| Setup | go get github.com/go-rod/rod | npx playwright install |
| Sandbox | launcher.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 aparseRequest()helper - Always add loading indicators — inline spinners in submit buttons + global progress bar
hx-indicatorfor HTMX forms, JSbtn.disabled = truefor vanilla forms
Templates
- Co-locate
<script>with templates — don’t use external JS files for page-specific logic template.FuncMapfor shared helpers (add,sub,percent,deref,divFloat)- Partials for reusable components —
templates/partials/*.html
Testing
- Table-driven unit tests for pure logic (bracket generation, scoring permutations)
- Integration tests with real DB —
testing.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-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. - 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.