Files
thrillwiki_django_no_react/docs/ux/htmx-conventions.md

11 KiB

HTMX Conventions

This document outlines the standardized HTMX patterns and conventions used in ThrillWiki.

Request Attributes

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

Use the appropriate HTTP method for the action:

<!-- Read operations -->
<button hx-get="/parks/">Load Parks</button>

<!-- Create operations -->
<form hx-post="/parks/create/">...</form>

<!-- Update operations -->
<form hx-put="/parks/{{ park.id }}/edit/">...</form>

<!-- Delete operations -->
<button hx-delete="/parks/{{ park.id }}/">Delete</button>

hx-target

Specify where the response should be inserted:

<!-- Target by ID -->
<button hx-get="/content/" hx-target="#content-area">Load</button>

<!-- Target closest ancestor -->
<button hx-delete="/item/" hx-target="closest .item-row">Delete</button>

<!-- Target this element -->
<div hx-get="/refresh/" hx-target="this">Content</div>

hx-swap

Control how content is inserted:

<!-- Replace inner HTML (default) -->
<div hx-get="/content/" hx-swap="innerHTML">...</div>

<!-- Replace entire element -->
<div hx-get="/content/" hx-swap="outerHTML">...</div>

<!-- Insert before/after -->
<div hx-get="/content/" hx-swap="beforeend">...</div>
<div hx-get="/content/" hx-swap="afterbegin">...</div>

<!-- Delete element -->
<button hx-delete="/item/" hx-swap="delete">Remove</button>

<!-- No swap (for side effects only) -->
<button hx-post="/action/" hx-swap="none">Action</button>

hx-trigger

Define when requests are made:

<!-- On click (default for buttons) -->
<button hx-get="/content/" hx-trigger="click">Load</button>

<!-- On page load -->
<div hx-get="/content/" hx-trigger="load">...</div>

<!-- When element becomes visible -->
<div hx-get="/more/" hx-trigger="revealed">Load More</div>

<!-- On form input with debounce -->
<input hx-get="/search/" hx-trigger="keyup changed delay:300ms">

<!-- On blur for validation -->
<input hx-post="/validate/" hx-trigger="blur changed">

<!-- Custom event -->
<div hx-get="/refresh/" hx-trigger="refresh from:body">...</div>

Response Headers

Server-Side Response Helpers

Use the standardized HTMX utility functions in views:

from apps.core.htmx_utils import (
    htmx_success,
    htmx_error,
    htmx_redirect,
    htmx_modal_close,
    htmx_refresh,
)

def create_park(request):
    # On success
    return htmx_success('Park created successfully!')

    # On error
    return htmx_error('Validation failed', status=422)

    # Redirect
    return htmx_redirect('/parks/')

    # Close modal with refresh
    return htmx_modal_close(
        message='Park saved!',
        refresh_target='#parks-list'
    )

HX-Trigger

Trigger client-side events from the server:

# Single event
response['HX-Trigger'] = 'parkCreated'

# Event with data
response['HX-Trigger'] = json.dumps({
    'showToast': {
        'type': 'success',
        'message': 'Park created!'
    }
})

# Multiple events
response['HX-Trigger'] = json.dumps({
    'closeModal': True,
    'refreshList': {'target': '#parks-list'},
    'showToast': {'type': 'success', 'message': 'Done!'}
})

HX-Redirect

Perform client-side navigation:

response['HX-Redirect'] = '/parks/'

HX-Refresh

Trigger a full page refresh:

response['HX-Refresh'] = 'true'

HX-Retarget / HX-Reswap

Override target and swap method:

response['HX-Retarget'] = '#different-target'
response['HX-Reswap'] = 'outerHTML'

Partial Templates

Convention

For each full template, create a corresponding partial:

templates/
  parks/
    list.html           # Full page
    list_partial.html   # Just the content (for HTMX)
    detail.html
    detail_partial.html

Usage with Decorator

from apps.core.htmx_utils import htmx_partial

@htmx_partial('parks/list.html')
def park_list(request):
    parks = Park.objects.all()
    return ({'parks': parks},)  # Returns context tuple

Manual Detection

from apps.core.htmx_utils import is_htmx_request

def park_list(request):
    parks = Park.objects.all()

    if is_htmx_request(request):
        return render(request, 'parks/list_partial.html', {'parks': parks})

    return render(request, 'parks/list.html', {'parks': parks})

Loading States

Indicator Attribute

<button hx-get="/content/"
        hx-indicator="#loading-spinner">
    Load Content
</button>

<div id="loading-spinner" class="htmx-indicator">
    <i class="fas fa-spinner fa-spin"></i>
</div>

CSS Classes

HTMX automatically adds classes during requests:

/* Added to element making request */
.htmx-request {
    opacity: 0.5;
    pointer-events: none;
}

/* Added to indicator elements */
.htmx-indicator {
    display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
    display: inline-block;
}

Skeleton Screens

Use skeleton components for better UX:

<div id="parks-list"
     hx-get="/parks/"
     hx-trigger="load"
     hx-swap="innerHTML">
    {% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
</div>

Form Handling

Standard Form Pattern

<form id="park-form"
      hx-post="{% url 'parks:create' %}"
      hx-target="#park-form"
      hx-swap="outerHTML"
      hx-indicator="#form-loading">
    {% csrf_token %}

    {% include 'forms/partials/form_field.html' with field=form.name %}
    {% include 'forms/partials/form_field.html' with field=form.location %}

    <div id="form-loading" class="htmx-indicator">
        Saving...
    </div>

    <button type="submit" class="btn btn-primary">Save</button>
</form>

Inline Validation

<input type="text"
       name="name"
       hx-post="{% url 'parks:validate_name' %}"
       hx-trigger="blur changed delay:500ms"
       hx-target="#name-feedback"
       hx-swap="innerHTML">
<div id="name-feedback"></div>

Server Response

from apps.core.htmx_utils import htmx_validation_response

def validate_name(request):
    name = request.POST.get('name', '')

    if not name:
        return htmx_validation_response('name', errors=['Name is required'])

    if Park.objects.filter(name=name).exists():
        return htmx_validation_response('name', errors=['Name already exists'])

    return htmx_validation_response('name', success_message='Name is available')

Pagination

Load More Pattern

<div id="parks-list">
    {% for park in parks %}
        {% include 'parks/_card.html' %}
    {% endfor %}
</div>

{% if page_obj.has_next %}
<button hx-get="?page={{ page_obj.next_page_number }}"
        hx-target="#parks-list"
        hx-swap="beforeend"
        hx-select="#parks-list > *"
        class="btn btn-outline">
    Load More
</button>
{% endif %}

Infinite Scroll

{% if page_obj.has_next %}
<div hx-get="?page={{ page_obj.next_page_number }}"
     hx-trigger="revealed"
     hx-target="this"
     hx-swap="outerHTML"
     hx-select="oob-swap">
    <div class="htmx-indicator text-center py-4">
        <i class="fas fa-spinner fa-spin"></i> Loading...
    </div>
</div>
{% endif %}

Traditional Pagination

{% include 'components/pagination.html' with
    page_obj=page_obj
    hx_target='#parks-list'
    hx_swap='innerHTML'
    hx_push_url='true'
%}

Modal Integration

Opening Modal with Content

<button hx-get="{% url 'parks:edit' park.id %}"
        hx-target="#modal-content"
        hx-swap="innerHTML"
        @click="showEditModal = true">
    Edit Park
</button>

<div x-show="showEditModal" @close-modal.window="showEditModal = false">
    <div id="modal-content">
        <!-- Content loaded here -->
    </div>
</div>

Closing Modal from Server

def save_park(request, pk):
    park = get_object_or_404(Park, pk=pk)
    form = ParkForm(request.POST, instance=park)

    if form.is_valid():
        form.save()
        return htmx_modal_close(
            message='Park updated successfully!',
            refresh_target='#park-detail'
        )

    return render(request, 'parks/_edit_form.html', {'form': form})

Out-of-Band Updates

Multiple Element Updates

<!-- Main response -->
<div id="main-content">
    Updated content here
</div>

<!-- Out-of-band updates -->
<div id="notification-count" hx-swap-oob="true">
    <span class="badge">5</span>
</div>

<div id="sidebar-status" hx-swap-oob="true">
    <span class="status">Online</span>
</div>

Server-Side OOB

def update_item(request, pk):
    item = get_object_or_404(Item, pk=pk)
    # ... update logic ...

    # Render multiple fragments
    main_html = render_to_string('items/_detail.html', {'item': item})
    count_html = render_to_string('items/_count.html', {'count': Item.objects.count()})

    return HttpResponse(
        main_html +
        f'<div id="item-count" hx-swap-oob="true">{count_html}</div>'
    )

Error Handling

HTTP Status Codes

HTMX handles different status codes:

  • 2xx: Normal swap
  • 204: No swap (success, no content)
  • 4xx: Swap (show validation errors)
  • 5xx: Trigger error event

Client-Side Error Handling

document.body.addEventListener('htmx:responseError', (event) => {
    const status = event.detail.xhr.status;

    if (status === 401) {
        Alpine.store('toast').error('Please log in to continue');
        window.location.href = '/login/';
    } else if (status >= 500) {
        Alpine.store('toast').error('Server error. Please try again.');
    }
});

Validation Errors

Return form HTML with errors on 422:

def create_park(request):
    form = ParkForm(request.POST)

    if not form.is_valid():
        return render(
            request,
            'parks/_form.html',
            {'form': form},
            status=422
        )

    park = form.save()
    return htmx_success(f'{park.name} created!')

Best Practices

1. Use Semantic URLs

<!-- Good -->
<button hx-delete="/parks/123/">Delete</button>

<!-- Avoid -->
<button hx-post="/parks/123/delete/">Delete</button>

2. Include CSRF Token

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

3. Use hx-confirm for Destructive Actions

<button hx-delete="/parks/123/"
        hx-confirm="Are you sure you want to delete this park?">
    Delete
</button>

4. Preserve Scroll Position

<div hx-get="/parks/"
     hx-swap="innerHTML show:top">

5. Push URL for Navigation

<a hx-get="/parks/123/"
   hx-target="#main"
   hx-push-url="true">
    View Park
</a>

6. Handle Browser Back Button

window.addEventListener('popstate', (event) => {
    if (event.state && event.state.htmx) {
        htmx.ajax('GET', window.location.href, '#main');
    }
});