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:
- Submit form (full page reload)
- Server validates
- Render full page with errors
With HTMX:
- Submit form (AJAX)
- Server validates
- 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
- Return 422 for validation errors - Helps HTMX distinguish from success
- Use
hx-target="this"- Replace the form with errors - Show loading states -
htmx-indicatorclass - Animate error appearance - CSS transitions
- 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.