Django and HTMX: Handling Form Validation Errors Cleanly

django htmx

Form validation with HTMX requires different patterns than traditional Django forms. Here’s how to handle validation errors elegantly.

The Challenge

Traditional Django:

  1. Submit form (full page reload)
  2. Server validates
  3. Render full page with errors

With HTMX:

  1. Submit form (AJAX)
  2. Server validates
  3. Return only the form (or just errors)

The partial response needs to handle both success and validation errors.

Pattern 1: Replace Entire Form

The simplest approach—return the complete form on error.

View

from django.shortcuts import render, redirect
from django.http import HttpResponse

def contact_form(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            form.save()
            # Return success message
            return HttpResponse('''
                <div class="success">Message sent!</div>
            ''')
        # Return form with errors
        return render(request, 'partials/contact_form.html', {'form': form})
    
    form = ContactForm()
    return render(request, 'partials/contact_form.html', {'form': form})

Template

<!-- partials/contact_form.html -->
<form hx-post="{% url 'contact' %}"
      hx-target="this"
      hx-swap="outerHTML">
    {% csrf_token %}
    
    {{ form.non_field_errors }}
    
    {% for field in form %}
        <div class="field {% if field.errors %}has-error{% endif %}">
            {{ field.label_tag }}
            {{ field }}
            {% if field.errors %}
                <span class="error">{{ field.errors.0 }}</span>
            {% endif %}
        </div>
    {% endfor %}
    
    <button type="submit">Send</button>
</form>

Pattern 2: Out-of-Band Error Updates

Update only the error elements without re-rendering the whole form.

View

from django.http import HttpResponse

def contact_form_oob(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            form.save()
            return HttpResponse('<div class="success">Sent!</div>')
        
        # Build OOB response with just errors
        error_html = ''
        for field_name, errors in form.errors.items():
            if field_name == '__all__':
                error_html += f'''
                    <div id="non-field-errors" hx-swap-oob="true">
                        {errors.as_text()}
                    </div>
                '''
            else:
                error_html += f'''
                    <span id="error-{field_name}" 
                          class="error"
                          hx-swap-oob="true">
                        {errors[0]}
                    </span>
                '''
        
        # Clear errors for valid fields
        for field_name in form.fields:
            if field_name not in form.errors:
                error_html += f'''
                    <span id="error-{field_name}" 
                          class="error"
                          hx-swap-oob="true"></span>
                '''
        
        return HttpResponse(error_html, status=422)
    
    return render(request, 'contact_form.html', {'form': ContactForm()})

Template

<form hx-post="{% url 'contact' %}"
      hx-target="#form-result"
      hx-swap="innerHTML">
    {% csrf_token %}
    
    <div id="non-field-errors"></div>
    
    <div class="field">
        <label for="id_email">Email</label>
        {{ form.email }}
        <span id="error-email" class="error"></span>
    </div>
    
    <div class="field">
        <label for="id_message">Message</label>
        {{ form.message }}
        <span id="error-message" class="error"></span>
    </div>
    
    <button type="submit">Send</button>
</form>

<div id="form-result"></div>

Pattern 3: Inline Validation

Validate fields as user types.

View

def validate_field(request):
    field_name = request.POST.get('field_name')
    value = request.POST.get('value')
    
    # Create form with just this field
    form = ContactForm({field_name: value})
    form.is_valid()
    
    if field_name in form.errors:
        return HttpResponse(
            f'<span class="error">{form.errors[field_name][0]}</span>',
            status=422
        )
    return HttpResponse('<span class="valid">✓</span>')

Template

<input type="email" 
       name="email"
       hx-post="{% url 'validate_field' %}"
       hx-trigger="change, blur"
       hx-target="next .validation-result"
       hx-vals='{"field_name": "email", "value": this.value}'>
<span class="validation-result"></span>

Pattern 4: Using django-htmx

The django-htmx package simplifies detection.

from django_htmx.http import trigger_client_event

def contact_form(request):
    form = ContactForm(request.POST or None)
    
    if request.method == 'POST':
        if form.is_valid():
            form.save()
            response = render(request, 'partials/success.html')
            # Trigger client-side event
            return trigger_client_event(response, 'formSuccess')
        
        # Return form with errors
        if request.htmx:
            return render(request, 'partials/form.html', {'form': form})
    
    return render(request, 'contact.html', {'form': form})

Pattern 5: Toast Notifications

Show errors as toast messages.

View

def contact_form_toast(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            form.save()
            return HttpResponse('''
                <div id="toast" hx-swap-oob="true" class="toast success">
                    Message sent!
                </div>
            ''')
        
        # Aggregate errors
        errors = []
        for field, error_list in form.errors.items():
            errors.extend(error_list)
        
        return HttpResponse(f'''
            <div id="toast" hx-swap-oob="true" class="toast error">
                {", ".join(errors)}
            </div>
        ''', status=422)

CSS for Visual Feedback

.field.has-error input,
.field.has-error textarea {
    border-color: #dc3545;
    background-color: #fff5f5;
}

.error {
    color: #dc3545;
    font-size: 0.875rem;
    display: block;
    margin-top: 0.25rem;
}

.valid {
    color: #28a745;
}

/* Loading state */
.htmx-request button[type="submit"] {
    opacity: 0.5;
    pointer-events: none;
}

/* Transition for error messages */
.error {
    animation: fadeIn 0.2s ease-in;
}

@keyframes fadeIn {
    from { opacity: 0; transform: translateY(-5px); }
    to { opacity: 1; transform: translateY(0); }
}

Complete Example

# views.py
from django.shortcuts import render
from django.http import HttpResponse

def create_post(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save()
            return HttpResponse(f'''
                <div class="success">
                    Post created! <a href="{post.get_absolute_url()}">View</a>
                </div>
            ''')
        return render(request, 'posts/partials/form.html', {
            'form': form
        }, status=422)
    
    form = PostForm()
    if request.htmx:
        return render(request, 'posts/partials/form.html', {'form': form})
    return render(request, 'posts/create.html', {'form': form})
<!-- posts/partials/form.html -->
<form id="post-form"
      hx-post="{% url 'create_post' %}"
      hx-target="this"
      hx-swap="outerHTML">
    {% csrf_token %}
    
    {% if form.non_field_errors %}
        <div class="alert error">
            {{ form.non_field_errors }}
        </div>
    {% endif %}
    
    <div class="field {% if form.title.errors %}has-error{% endif %}">
        <label for="{{ form.title.id_for_label }}">Title</label>
        {{ form.title }}
        {% if form.title.errors %}
            <span class="error">{{ form.title.errors.0 }}</span>
        {% endif %}
    </div>
    
    <div class="field {% if form.content.errors %}has-error{% endif %}">
        <label for="{{ form.content.id_for_label }}">Content</label>
        {{ form.content }}
        {% if form.content.errors %}
            <span class="error">{{ form.content.errors.0 }}</span>
        {% endif %}
    </div>
    
    <button type="submit">
        <span class="htmx-indicator">Saving...</span>
        <span>Create Post</span>
    </button>
</form>

Best Practices

  1. Return 422 for validation errors - Helps HTMX distinguish from success
  2. Use hx-target="this" - Replace the form with errors
  3. Show loading states - htmx-indicator class
  4. Animate error appearance - CSS transitions
  5. Clear old errors - When corrected fields are submitted

Final Thoughts

HTMX form validation requires thinking in partials. The server returns exactly what needs updating—the whole form, just errors, or a success message.

Start with Pattern 1 (replace entire form), optimize to OOB if needed.


Forms that feel instant, validated on the server.

All posts