Files
Code_of_Conquest/public_web/docs/HTMX_PATTERNS.md
2025-11-24 23:10:55 -06:00

14 KiB

HTMX Integration Patterns - Public Web Frontend

Last Updated: November 18, 2025


Overview

This document outlines HTMX usage patterns, best practices, and common implementations for the Code of Conquest web frontend.

HTMX Benefits:

  • Server-side rendering with dynamic interactions
  • No JavaScript framework overhead
  • Progressive enhancement (works without JS)
  • Clean separation of concerns
  • Natural integration with Flask/Jinja2

Core HTMX Attributes

hx-get / hx-post / hx-put / hx-delete

Make HTTP requests from HTML elements:

<!-- GET request -->
<button hx-get="/api/v1/characters" hx-target="#characters">
    Load Characters
</button>

<!-- POST request -->
<form hx-post="/api/v1/characters" hx-target="#result">
    <!-- form fields -->
    <button type="submit">Create</button>
</form>

<!-- DELETE request -->
<button hx-delete="/api/v1/characters/{{ character_id }}"
        hx-target="closest .character-card"
        hx-swap="outerHTML">
    Delete
</button>

hx-target

Specify where to insert the response:

<!-- CSS selector -->
<button hx-get="/content" hx-target="#content-div">Load</button>

<!-- Relative selectors -->
<button hx-delete="/item" hx-target="closest .item">Delete</button>
<button hx-post="/append" hx-target="next .list">Add</button>

<!-- Special values -->
<button hx-get="/modal" hx-target="body">Show Modal</button>

hx-swap

Control how content is swapped:

<!-- innerHTML (default) -->
<div hx-get="/content" hx-target="#div" hx-swap="innerHTML"></div>

<!-- outerHTML (replace element itself) -->
<div hx-get="/content" hx-target="#div" hx-swap="outerHTML"></div>

<!-- beforebegin (before target) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforebegin"></div>

<!-- afterbegin (first child) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterbegin"></div>

<!-- beforeend (last child) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforeend"></div>

<!-- afterend (after target) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterend"></div>

<!-- none (no swap, just trigger) -->
<div hx-get="/trigger" hx-swap="none"></div>

hx-trigger

Specify what triggers the request:

<!-- Click (default for buttons) -->
<button hx-get="/content">Click Me</button>

<!-- Change (default for inputs) -->
<select hx-get="/filter" hx-trigger="change">...</select>

<!-- Custom events -->
<div hx-get="/content" hx-trigger="customEvent">...</div>

<!-- Multiple triggers -->
<div hx-get="/content" hx-trigger="mouseenter, focus">...</div>

<!-- Trigger modifiers -->
<input hx-get="/search"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#results">

<!-- Polling -->
<div hx-get="/status" hx-trigger="every 5s">Status: ...</div>

<!-- Load once -->
<div hx-get="/content" hx-trigger="load">...</div>

Common Patterns

Form Submission

Pattern: Submit form via AJAX, replace form with result

<form hx-post="/api/v1/characters"
      hx-target="#form-container"
      hx-swap="outerHTML">

    <input type="text" name="name" placeholder="Character Name" required>

    <select name="class">
        <option value="vanguard">Vanguard</option>
        <option value="luminary">Luminary</option>
    </select>

    <button type="submit">Create Character</button>
</form>

Backend Response:

@characters_bp.route('/', methods=['POST'])
def create_character():
    # Create character via API
    response = api_client.post('/characters', request.form)

    if response.status_code == 200:
        character = response.json()['result']
        return render_template('partials/character_card.html', character=character)
    else:
        return render_template('partials/form_error.html', error=response.json()['error'])

Delete with Confirmation

Pattern: Confirm before deleting, remove element on success

<div class="character-card" id="char-{{ character.character_id }}">
    <h3>{{ character.name }}</h3>

    <button hx-delete="/api/v1/characters/{{ character.character_id }}"
            hx-confirm="Are you sure you want to delete {{ character.name }}?"
            hx-target="closest .character-card"
            hx-swap="outerHTML swap:1s">
        Delete
    </button>
</div>

Backend Response (empty for delete):

@characters_bp.route('/<character_id>', methods=['DELETE'])
def delete_character(character_id):
    api_client.delete(f'/characters/{character_id}')
    return '', 200  # Empty response removes element

Search/Filter

Pattern: Live search with debouncing

<input type="text"
       name="search"
       placeholder="Search characters..."
       hx-get="/characters/search"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#character-list"
       hx-indicator="#search-spinner">

<span id="search-spinner" class="htmx-indicator">Searching...</span>

<div id="character-list">
    <!-- Results appear here -->
</div>

Backend:

@characters_bp.route('/search')
def search_characters():
    query = request.args.get('search', '')
    characters = api_client.get(f'/characters?search={query}')
    return render_template('partials/character_list.html', characters=characters)

Pagination

Pattern: Load more items

<div id="character-list">
    {% for character in characters %}
        {% include 'partials/character_card.html' %}
    {% endfor %}
</div>

{% if has_more %}
<button hx-get="/characters?page={{ page + 1 }}"
        hx-target="#character-list"
        hx-swap="beforeend"
        hx-select=".character-card">
    Load More
</button>
{% endif %}

Inline Edit

Pattern: Click to edit, save inline

<div class="character-name" id="name-{{ character.character_id }}">
    <span hx-get="/characters/{{ character.character_id }}/edit-name"
          hx-target="closest div"
          hx-swap="outerHTML">
        {{ character.name }} ✏️
    </span>
</div>

Edit Form Response:

<div class="character-name" id="name-{{ character.character_id }}">
    <form hx-put="/characters/{{ character.character_id }}/name"
          hx-target="closest div"
          hx-swap="outerHTML">
        <input type="text" name="name" value="{{ character.name }}" autofocus>
        <button type="submit">Save</button>
        <button type="button" hx-get="/characters/{{ character.character_id }}/name" hx-target="closest div" hx-swap="outerHTML">Cancel</button>
    </form>
</div>

Modal Dialog

Pattern: Load modal content dynamically

<button hx-get="/characters/{{ character.character_id }}/details"
        hx-target="#modal-container"
        hx-swap="innerHTML">
    View Details
</button>

<div id="modal-container"></div>

Modal Response:

<div class="modal" id="character-modal">
    <div class="modal-content">
        <span class="close" onclick="closeModal()">&times;</span>
        <h2>{{ character.name }}</h2>
        <!-- character details -->
    </div>
</div>

<script>
function closeModal() {
    document.getElementById('character-modal').remove();
}
</script>

Tabs

Pattern: Tab switching without page reload

<div class="tabs">
    <button hx-get="/dashboard/overview"
            hx-target="#tab-content"
            class="tab active">
        Overview
    </button>
    <button hx-get="/dashboard/characters"
            hx-target="#tab-content"
            class="tab">
        Characters
    </button>
    <button hx-get="/dashboard/sessions"
            hx-target="#tab-content"
            class="tab">
        Sessions
    </button>
</div>

<div id="tab-content">
    <!-- Tab content loads here -->
</div>

Polling for Updates

Pattern: Auto-refresh session status

<div hx-get="/sessions/{{ session_id }}/status"
     hx-trigger="every 5s"
     hx-target="#session-status">
    Loading status...
</div>

Conditional Polling (stop when complete):

<div hx-get="/sessions/{{ session_id }}/status"
     hx-trigger="every 5s[status !== 'completed']"
     hx-target="#session-status">
    Status: {{ session.status }}
</div>

Infinite Scroll

Pattern: Load more as user scrolls

<div id="character-list">
    {% for character in characters %}
        {% include 'partials/character_card.html' %}
    {% endfor %}
</div>

{% if has_more %}
<div hx-get="/characters?page={{ page + 1 }}"
     hx-trigger="revealed"
     hx-target="#character-list"
     hx-swap="beforeend">
    Loading more...
</div>
{% endif %}

Advanced Patterns

Dependent Dropdowns

Pattern: Update second dropdown based on first selection

<select name="class"
        hx-get="/characters/abilities"
        hx-target="#ability-select"
        hx-trigger="change">
    <option value="vanguard">Vanguard</option>
    <option value="luminary">Luminary</option>
</select>

<div id="ability-select">
    <!-- Abilities dropdown loads here based on class -->
</div>

Out of Band Swaps

Pattern: Update multiple areas from single response

<button hx-post="/combat/attack"
        hx-target="#combat-log"
        hx-swap="beforeend">
    Attack
</button>

<div id="combat-log"><!-- Combat log --></div>
<div id="character-hp">HP: 50/100</div>

Backend Response:

<!-- Primary swap target -->
<div>You dealt 15 damage!</div>

<!-- Out of band swap -->
<div id="character-hp" hx-swap-oob="true">HP: 45/100</div>

Optimistic UI

Pattern: Show result immediately, revert on error

<button hx-post="/characters/{{ character_id }}/favorite"
        hx-target="#favorite-btn"
        hx-swap="outerHTML"
        class="btn">
    ⭐ Favorite
</button>

Immediate feedback with CSS:

.htmx-request .htmx-indicator {
    display: inline;
}

Progressive Enhancement

Pattern: Fallback to normal form submission

<form action="/characters" method="POST"
      hx-post="/characters"
      hx-target="#result">
    <!-- If HTMX fails, form still works via normal POST -->
    <input type="text" name="name">
    <button type="submit">Create</button>
</form>

HTMX + Appwrite Realtime

Hybrid Approach

Use HTMX for user actions, Appwrite Realtime for server push updates:

<!-- HTMX for user actions -->
<button hx-post="/sessions/{{ session_id }}/ready"
        hx-target="#lobby-status">
    Ready
</button>

<!-- Appwrite Realtime for live updates -->
<script>
const client = new Appwrite.Client()
    .setEndpoint('{{ config.appwrite_endpoint }}')
    .setProject('{{ config.appwrite_project_id }}');

client.subscribe('channel', response => {
    // Trigger HTMX to reload specific section
    htmx.ajax('GET', '/sessions/{{ session_id }}/status', {
        target: '#session-status',
        swap: 'innerHTML'
    });
});
</script>

Error Handling

Show Error Messages

<form hx-post="/characters"
      hx-target="#form-container"
      hx-target-error="#error-container">
    <!-- form fields -->
</form>

<div id="error-container"></div>

Backend Error Response:

@characters_bp.route('/', methods=['POST'])
def create_character():
    try:
        # Create character
        pass
    except ValidationError as e:
        response = make_response(render_template('partials/error.html', error=str(e)), 400)
        response.headers['HX-Retarget'] = '#error-container'
        return response

Retry on Failure

<div hx-get="/api/v1/data"
     hx-trigger="load, error from:body delay:5s">
    Loading...
</div>

Loading Indicators

Global Indicator

<style>
.htmx-indicator {
    display: none;
}
.htmx-request .htmx-indicator {
    display: inline;
}
</style>

<button hx-post="/action" hx-indicator="#spinner">
    Submit
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>

Inline Indicator

<button hx-get="/content">
    <span class="button-text">Load</span>
    <span class="htmx-indicator">Loading...</span>
</button>

Best Practices

1. Use Semantic HTML

<!-- Good -->
<button hx-post="/action">Submit</button>

<!-- Avoid -->
<div hx-post="/action">Submit</div>

2. Provide Fallbacks

<form action="/submit" method="POST" hx-post="/submit">
    <!-- Works without JavaScript -->
</form>

3. Use hx-indicator for Loading States

<button hx-post="/action" hx-indicator="#spinner">
    Submit
</button>
<span id="spinner" class="htmx-indicator"></span>

4. Debounce Search Inputs

<input hx-get="/search"
       hx-trigger="keyup changed delay:500ms">

5. Use CSRF Protection

<form hx-post="/action">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>

Debugging

HTMX Events

Listen to HTMX events for debugging:

<script>
document.body.addEventListener('htmx:beforeRequest', (event) => {
    console.log('Before request:', event.detail);
});

document.body.addEventListener('htmx:afterRequest', (event) => {
    console.log('After request:', event.detail);
});

document.body.addEventListener('htmx:responseError', (event) => {
    console.error('Response error:', event.detail);
});
</script>

HTMX Logger Extension

<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
<body hx-ext="debug">

Performance Tips

1. Use hx-select to Extract Partial

<button hx-get="/full-page"
        hx-select="#content-section"
        hx-target="#result">
    Load Section
</button>

2. Disable During Request

<button hx-post="/action" hx-disable-during-request>
    Submit
</button>

3. Use hx-sync for Sequential Requests

<div hx-sync="this:replace">
    <button hx-get="/content">Load</button>
</div>


Document Version: 1.1 Created: November 18, 2025 Last Updated: November 21, 2025