16 KiB
HTMX Atomic Updates Implementation
Overview
This document describes the atomic update patterns using HTMX's out-of-band (OOB) swaps. These patterns are used throughout the CV application for language switching, theme toggling, length control, and logo visibility. The solution follows HTMX best practices by updating only the components that change, avoiding full-page reloads and maintaining clean URLs.
Architecture
The Problem
When switching languages, we need to update TWO distinct UI components:
- The language selector buttons (to show which language is active)
- The CV content (to display translated text)
The Solution: Out-of-Band Swaps
We use HTMX's hx-swap-oob feature to update multiple DOM elements from a single server response:
Single Request → Single Response → Multiple Atomic Updates
Implementation Details
1. Frontend: Language Selector Buttons
File: templates/partials/navigation/language-selector.html
<div class="language-selector" id="language-selector">
<button class="selector-btn {{if eq .Lang "en"}}active{{end}}"
hx-get="/switch-language?lang=en"
hx-target="#language-selector"
hx-swap="outerHTML"
hx-push-url="/?lang=en">
English
</button>
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}"
hx-get="/switch-language?lang=es"
hx-target="#language-selector"
hx-swap="outerHTML"
hx-push-url="/?lang=es">
Español
</button>
</div>
Key Attributes:
hx-get="/switch-language?lang=XX"- Endpoint that returns multiple fragmentshx-target="#language-selector"- Primary target (the language selector itself)hx-swap="outerHTML"- Replace the entire selector elementhx-push-url="/?lang=XX"- Update browser URL for bookmarkability
2. Backend: Server Response Template
File: templates/language-switch.html
The server returns three elements in a single response:
<!-- PRIMARY TARGET: Language Selector -->
<div class="language-selector" id="language-selector">
<!-- Updated buttons with correct "active" state -->
</div>
<!-- OUT-OF-BAND SWAP #1: Page 1 Content -->
<div id="cv-inner-content-page-1"
class="cv-page-content-wrapper"
hx-swap-oob="innerHTML swap:200ms settle:200ms">
<!-- Translated content for page 1 -->
</div>
<!-- OUT-OF-BAND SWAP #2: Page 2 Content -->
<div id="cv-inner-content-page-2"
class="cv-page-content-wrapper"
hx-swap-oob="innerHTML swap:200ms settle:200ms">
<!-- Translated content for page 2 -->
</div>
Out-of-Band Swap Syntax:
hx-swap-oob="innerHTML swap:200ms settle:200ms"innerHTML- Replace content inside the target elementswap:200ms- Fade out old content over 200mssettle:200ms- Fade in new content over 200ms
3. Backend: Handler Function
File: internal/handlers/cv.go
func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) {
// 1. Get and validate language
lang := r.URL.Query().Get("lang")
if lang != "en" && lang != "es" {
HandleError(w, r, BadRequestError("Unsupported language"))
return
}
// 2. Save language preference in cookie
setPreferenceCookie(w, "cv-language", lang)
// 3. Load translated CV data and UI strings
data, err := h.prepareTemplateData(lang)
// 4. Preserve other user preferences (length, logos, theme)
cvLength := getPreferenceCookie(r, "cv-length", "short")
cvLogos := getPreferenceCookie(r, "cv-logos", "show")
data["CVLengthClass"] = cvLength
data["ShowLogos"] = (cvLogos == "show")
// 5. Render template with out-of-band swaps
tmpl, err := h.templates.Render("language-switch.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}
4. Routing
File: internal/routes/routes.go
mux.HandleFunc("/switch-language", cvHandler.SwitchLanguage)
Request/Response Flow
User clicks "Español" button
↓
HTMX sends: GET /switch-language?lang=es
↓
Server processes:
1. Validates lang=es
2. Loads Spanish CV data from YAML
3. Loads Spanish UI translations
4. Renders language-switch.html template
↓
Server returns HTML with 3 elements:
1. Language selector (primary target)
2. Page 1 content (hx-swap-oob)
3. Page 2 content (hx-swap-oob)
↓
HTMX processes response:
1. Swaps #language-selector (outerHTML)
2. Swaps #cv-inner-content-page-1 (innerHTML with fade)
3. Swaps #cv-inner-content-page-2 (innerHTML with fade)
4. Pushes /?lang=es to browser history
↓
User sees:
✓ "Español" button highlighted
✓ All content translated to Spanish
✓ URL updated to /?lang=es
✓ Smooth 200ms fade transition
Performance
- Response Time: < 10ms (measured)
- Response Size: ~50KB (compressed HTML)
- Network Requests: 1 (vs 1 for full page reload, but much smaller payload)
- DOM Updates: 3 atomic swaps
- Transitions: 200ms fade for smooth UX
Advantages of This Pattern
- Atomic Updates - All changes happen together, no flashing
- Minimal Payload - Only sends what changes, not the entire page
- No JavaScript - Pure HTMX attributes, no custom JS needed
- Smooth Transitions - Built-in 200ms fade for professional UX
- URL Updates - Bookmarkable, shareable language-specific URLs
- State Preservation - Other preferences (length, logos) are maintained
- Accessibility - Works with screen readers, keyboard navigation
- SEO Friendly - Search engines can index language-specific URLs
Testing
Manual Test
- Open http://localhost:1999/?lang=en
- Click "Español" button
- Verify:
- ✓ "Español" button is now highlighted (active)
- ✓ All content is in Spanish
- ✓ URL changed to /?lang=es
- ✓ Smooth fade transition occurred
Automated Test with curl
# Test Spanish switch
curl -s "http://localhost:1999/switch-language?lang=es" | grep "hx-swap-oob"
# Should return 2 lines (one for each page)
# Test English switch
curl -s "http://localhost:1999/switch-language?lang=en" | grep "Technical Skills"
# Should return English content
# Test Spanish content
curl -s "http://localhost:1999/switch-language?lang=es" | grep "Competencias Técnicas"
# Should return Spanish content
HTMX Patterns Used
Pattern: Out-of-Band Swaps
Purpose: Update multiple DOM elements from a single server response
Implementation:
- Primary target receives normal HTMX swap
- Additional elements marked with
hx-swap-oob="true"are swapped automatically - HTMX matches elements by
idattribute
Benefits:
- Single round trip to server
- Multiple atomic updates
- No client-side coordination needed
Pattern: Push URL
Purpose: Update browser history without full page reload
Implementation:
hx-push-url="/?lang=XX"on button- Browser history updated after swap
- Back/forward buttons work correctly
Benefits:
- Bookmarkable URLs
- Shareable links
- SEO-friendly
Pattern: Swap Timing
Purpose: Smooth transitions between content states
Implementation:
swap:200ms- Time to fade out old contentsettle:200ms- Time to fade in new content
Benefits:
- Professional polish
- Reduces jarring changes
- Better perceived performance
Comparison: Before vs After
Before (Full Page Reload)
// Required custom JavaScript
function switchLanguage(lang) {
window.location.href = `/?lang=${lang}`;
}
- Full page reload
- Lost scroll position
- Jarring flash
- ~300KB+ transfer
- ~100ms+ load time
After (HTMX Out-of-Band Swaps)
<!-- Pure declarative HTML -->
<button hx-get="/switch-language?lang=es"
hx-target="#language-selector"
hx-swap="outerHTML"
hx-push-url="/?lang=es">
- Partial page update
- Maintains scroll position
- Smooth 200ms fade
- ~50KB transfer
- ~10ms response time
URL Cleanliness Pattern
The Problem
Traditional anchor links (<a href="#section">) cause URL pollution:
- After clicking "back to top":
http://localhost:1999/?lang=es#top - The
#topanchor remains in browser history permanently - Anchors are useful for scrolling but should NOT persist in URL
The Solution: Hyperscript Smooth Scrolling
Instead of anchors, we use hyperscript to scroll WITHOUT updating the URL:
<!-- Back to top button -->
<button id="back-to-top"
_="on click
call event.preventDefault()
set window.scrollTo({top: 0, behavior: 'smooth'})">
<iconify-icon icon="mdi:arrow-up"></iconify-icon>
</button>
<!-- Section navigation link -->
<a href="#education"
_="on click
call event.preventDefault()
then call document.getElementById('education').scrollIntoView({behavior: 'smooth'})">
Education
</a>
Benefits:
- Clean URLs:
http://localhost:1999/?lang=es(no anchors) - Smooth scrolling preserved
- Browser history stays clean
- Only intentional state (language) in URL
Toggle Pattern: Atomic Out-of-Band Swaps
All toggles (theme, length, logos) follow the same atomic pattern:
Pattern Structure
- Desktop toggle - Primary HTMX target
- Mobile toggle - Out-of-band swap (synced automatically)
- Client-side effects - Hyperscript applies CSS classes and localStorage
Example: Theme Toggle
Template: templates/theme-toggle.html
<!-- Primary response: Desktop toggle -->
<div class="selector-group" id="desktop-theme-toggle">
<label class="icon-toggle">
<input type="checkbox"
id="themeToggle"
{{if .ThemeClean}}checked{{end}}
hx-post="/toggle/theme?lang={{.Lang}}"
hx-target="#desktop-theme-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .theme-clean to the body
set localStorage['cv-theme'] to 'clean'
else
remove .theme-clean from the body
set localStorage['cv-theme'] to 'default'
end">
<span class="icon-toggle-slider">...</span>
</label>
</div>
<!-- Out-of-band swap: Mobile toggle -->
<div class="menu-control-item" id="mobile-theme-toggle" hx-swap-oob="true">
<label class="icon-toggle">
<input type="checkbox"
id="themeToggleMenu"
{{if .ThemeClean}}checked{{end}}
hx-post="/toggle/theme?lang={{.Lang}}"
hx-target="#mobile-theme-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .theme-clean to the body
set localStorage['cv-theme'] to 'clean'
else
remove .theme-clean from the body
set localStorage['cv-theme'] to 'default'
end">
<span class="icon-toggle-slider">...</span>
</label>
</div>
Backend Handler:
func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) {
// Get current state
currentTheme := getPreferenceCookie(r, "cv-theme", "default")
// Toggle state
newTheme := "clean"
if currentTheme == "clean" {
newTheme = "default"
}
// Save preference in cookie
setPreferenceCookie(w, "cv-theme", newTheme)
// Minimal template data - just the new state
data := map[string]interface{}{
"Lang": r.URL.Query().Get("lang"),
"ThemeClean": (newTheme == "clean"),
}
// Render atomic template
tmpl, _ := h.templates.Render("theme-toggle.html")
tmpl.Execute(w, data)
}
Request Flow:
User clicks theme toggle
↓
HTMX sends: POST /toggle/theme?lang=es
↓
Server:
1. Reads cookie to get current state
2. Toggles state (default ↔ clean)
3. Saves new state in cookie
4. Returns minimal HTML (just the 2 toggle buttons)
↓
HTMX swaps:
1. Desktop toggle (primary target)
2. Mobile toggle (out-of-band)
↓
Hyperscript (on htmx:afterRequest):
1. Checks toggle state
2. Adds/removes .theme-clean class on body
3. Saves to localStorage
↓
User sees:
✓ Both toggles synced
✓ Theme applied instantly
✓ State persisted
✓ NO scroll position jump
✓ NO URL change
Why This Pattern?
Compared to Pure HTMX (server swaps body):
- ✅ Much smaller payload (~1KB vs ~50KB)
- ✅ Faster response time (~5ms vs ~20ms)
- ✅ No scroll position issues
- ✅ Simpler backend logic
Compared to Pure Hyperscript (no HTMX):
- ✅ Server is source of truth (better for bookmarking)
- ✅ Cookie persistence works across sessions
- ✅ Works with JavaScript disabled (degrades gracefully)
- ✅ Desktop/mobile sync guaranteed by server
The Hybrid Approach (HTMX + Hyperscript):
- HTMX handles the server round-trip and state persistence
- Hyperscript handles the immediate visual feedback
- Best of both worlds: fast UX + reliable state management
All Toggle Endpoints
| Endpoint | Purpose | Cookie | Template |
|---|---|---|---|
/toggle/theme |
Clean/Default theme | cv-theme |
theme-toggle.html |
/toggle/length |
Short/Long CV | cv-length |
length-toggle.html |
/toggle/logos |
Show/Hide logos | cv-logos |
logo-toggle.html |
/switch-language |
EN/ES language | cv-language |
language-switch.html |
Testing Toggles
Manual Test
# 1. Start server
go run cmd/server/main.go
# 2. Open browser
open http://localhost:1999/?lang=en
# 3. Test each toggle:
# - Click desktop toggle → verify visual change
# - Open mobile menu → verify mobile toggle is synced
# - Click mobile toggle → verify desktop toggle is synced
# - Refresh page → verify state persists
# - Check URL → verify it stays clean (no extra params)
Automated Test with curl
# Test theme toggle
curl -s -c cookies.txt "http://localhost:1999/toggle/theme?lang=en" | grep "hx-swap-oob"
# Should return 1 line (mobile toggle OOB swap)
# Verify cookie was set
cat cookies.txt | grep cv-theme
# Should show: cv-theme clean (or default)
# Test length toggle
curl -s -b cookies.txt -c cookies.txt "http://localhost:1999/toggle/length?lang=en" | grep "cv-long"
# Test logo toggle
curl -s -b cookies.txt "http://localhost:1999/toggle/logos?lang=en" | grep "show-logos"
Future Enhancements
Potential improvements to consider:
- Preload translations - Cache both languages on initial load
- Optimistic UI - Show toggle change immediately, reconcile with server
- Keyboard shortcuts - Alt+T for theme, Alt+L for length
- Auto-detection - Use browser preferences on first visit
- Loading indicator - Show spinner for slow connections (though toggles are <10ms)
- Undo/Redo - History stack for toggle changes
References
- HTMX Out-of-Band Swaps
- HTMX Push URL
- HTMX Swap Modifiers
- Hyperscript Documentation
- Locality of Behavior Principle
Design Principles
This implementation demonstrates modern hypermedia-driven architecture:
- Server as Source of Truth - All state persisted server-side (cookies)
- HTML as State Representation - Server returns minimal HTML fragments
- Declarative UI Updates - HTMX attributes declare behavior
- Progressive Enhancement - Works without JavaScript (falls back to full page reload)
- Minimal Payload - Only send what changes (~1KB per toggle)
- Zero Custom JavaScript - HTMX + Hyperscript handle all interactivity
- URL Cleanliness - Only intentional state in URLs (language parameter)
- Atomic Updates - Multiple components update together, no flashing
- Hybrid Approach - HTMX for server state + Hyperscript for immediate feedback
The pattern scales well for any multi-component state changes where:
- Multiple UI elements need to stay in sync (desktop/mobile)
- State should persist across sessions (cookies/localStorage)
- Fast user feedback is important (<10ms perceived latency)
- URLs should stay clean and bookmarkable