- Mindset Shifts
- Common Mistakes
- Django-Specific Issues
- Performance Pitfalls
- Debugging Challenges
- Best Practices to Avoid Issues
❌ Wrong Mindset (jQuery):
# Returning JSON
def get_users(request):
users = User.objects.all()
data = [{'id': u.id, 'name': u.name} for u in users]
return JsonResponse({'users': data})✅ Correct Mindset (HTMX):
# Returning HTML
def get_users(request):
users = User.objects.all()
return render(request, 'partials/users.html', {'users': users})Why It Matters:
- HTMX expects HTML responses, not JSON
- If you return JSON, nothing will happen (no errors, just nothing)
- This is the #1 mistake when transitioning from jQuery
❌ Wrong Mindset:
// Trying to maintain state in JavaScript
let selectedItems = [];
function addItem(id) {
selectedItems.push(id);
updateUI();
}✅ Correct Mindset:
# State lives on the server (or in the DOM)
def add_item(request):
item_id = request.POST.get('item_id')
# Add to session or database
request.session.setdefault('selected_items', []).append(item_id)
return render(request, 'partials/selected_items.html', {
'items': request.session['selected_items']
})Why It Matters:
- HTMX encourages server-side state management
- Client-side state should be minimal (in DOM attributes if needed)
- This reduces complexity and synchronization issues
❌ Wrong Mindset:
// Thinking like a SPA
class UserList extends React.Component {
// Complex component logic
}✅ Correct Mindset:
<!-- Thinking in hypermedia exchanges -->
<div hx-get="/users" hx-trigger="load">
<!-- Server sends the complete HTML -->
</div>Why It Matters:
- HTMX isn't a component framework
- Think about server exchanges, not component lifecycle
- Focus on request/response, not component state
❌ Problem:
<form hx-post="/submit/">
<!-- Missing CSRF token! -->
<input name="email">
<button type="submit">Submit</button>
</form>Result: POST requests fail with 403 Forbidden
✅ Solution:
<form hx-post="/submit/">
{% csrf_token %}
<input name="email">
<button type="submit">Submit</button>
</form>Alternative for Non-Form Requests:
<button hx-post="/api/action/"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
Action
</button>❌ Problem:
def submit_form(request):
if errors:
# Returns error HTML but with 200 status
return render(request, 'errors.html', {'errors': errors})Result: HTMX treats it as success; errors don't trigger error events
✅ Solution:
def submit_form(request):
if errors:
# Return appropriate status code
return render(request, 'errors.html',
{'errors': errors},
status=400)Status Code Guidelines:
200: Success400: Validation errors404: Not found500: Server error204: Success with no content (empty response)
❌ Problem:
<button hx-get="/data" hx-target="results">
<!-- Missing # for ID selector! -->
Load
</button>
<div id="results"></div>Result: Nothing happens; HTMX can't find the target
✅ Solution:
<button hx-get="/data" hx-target="#results">
Load
</button>
<div id="results"></div>Selector Options:
#id: Target by ID.class: Target by class (first match)this: Target the element itselfclosest .class: Find nearest parent with classnext .class: Next sibling with classprevious .class: Previous sibling with class
❌ Problem:
<form hx-post="/submit/">
{% csrf_token %}
<input type="text" id="email">
<!-- Missing name attribute! -->
<button type="submit">Submit</button>
</form>Result: Form data not sent to server
✅ Solution:
<form hx-post="/submit/">
{% csrf_token %}
<input type="text" id="email" name="email">
<button type="submit">Submit</button>
</form>Why It Matters:
- HTMX serializes form data using
nameattributes - IDs alone are not enough
- This is standard HTML behavior, but easy to forget
❌ Problem:
<!-- Want to add item to list -->
<button hx-get="/new-item"
hx-target="#item-list"
hx-swap="innerHTML">
Add Item
</button>Result: Replaces entire list with just the new item
✅ Solution:
<button hx-get="/new-item"
hx-target="#item-list"
hx-swap="beforeend">
<!-- Appends new item to list -->
Add Item
</button>Swap Options:
innerHTML: Replace contents (default)outerHTML: Replace element itselfbeforebegin: Before the elementafterbegin: As first childbeforeend: As last child (append)afterend: After the elementdelete: Remove the targetnone: Don't swap (useful for side effects)
❌ Problem:
def delete_item(request, item_id):
Item.objects.get(id=item_id).delete()
return HttpResponse('') # Empty response<button hx-delete="/items/{{ item.id }}/"
hx-target="#item-{{ item.id }}">
Delete
</button>Result: Item still visible (empty string is swapped in with innerHTML)
✅ Solution:
<button hx-delete="/items/{{ item.id }}/"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML">
<!-- outerHTML removes the element when response is empty -->
Delete
</button>Alternative:
<button hx-delete="/items/{{ item.id }}/"
hx-target="#item-{{ item.id }}"
hx-swap="delete">
Delete
</button>❌ Problem:
<!-- Triggers on every keypress -->
<input hx-get="/search" hx-trigger="keyup">Result: Excessive server requests; poor performance
✅ Solution:
<!-- Debounce with delay -->
<input hx-get="/search"
hx-trigger="keyup changed delay:300ms">Trigger Modifiers:
delay:Xms: Debouncethrottle:Xms: Rate limitchanged: Only if value changedonce: Only fire onceconsume: Prevent default
❌ Problem:
<button hx-get="/slow-endpoint">
Load Data
</button>Result: No feedback during loading; poor UX
✅ Solution:
<button hx-get="/slow-endpoint">
Load Data
<span class="htmx-indicator">⏳</span>
</button>
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
</style>Or with separate indicator:
<button hx-get="/slow-endpoint" hx-indicator="#spinner">
Load Data
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>Problem: Custom middleware that redirects or modifies responses can interfere with HTMX requests.
Solution: Check for HTMX requests in middleware:
def my_middleware(get_response):
def middleware(request):
# Check if it's an HTMX request
if request.headers.get('HX-Request'):
# Handle HTMX requests differently
pass
response = get_response(request)
return response
return middleware❌ Problem:
<!-- full_page.html -->
<div class="users">
{% for user in users %}
<div>{{ user.name }}</div>
{% endfor %}
</div>
<!-- partials/users.html (duplicate logic) -->
{% for user in users %}
<div>{{ user.name }}</div>
{% endfor %}✅ Solution: Use template includes
<!-- full_page.html -->
<div class="users">
{% include "partials/user_list.html" %}
</div>
<!-- partials/users.html (HTMX response) -->
{% include "partials/user_list.html" %}
<!-- partials/user_list.html (shared) -->
{% for user in users %}
<div>{{ user.name }}</div>
{% endfor %}❌ Problem:
<button hx-post="/examples/tasks/{{ task.id }}/delete/">
Delete
</button>Result: Brittle; breaks if URL structure changes
✅ Solution:
<button hx-post="{% url 'examples:task_delete_htmx' task.id %}">
Delete
</button>Problem: Not distinguishing between full page and HTMX requests
Solution: Use django-htmx or check headers
# Install: pip install django-htmx
# Check in view
def my_view(request):
if request.htmx:
# Return partial
return render(request, 'partials/content.html')
else:
# Return full page
return render(request, 'full_page.html')Manual check:
def my_view(request):
is_htmx = request.headers.get('HX-Request') == 'true'❌ Problem:
def update_item(request, item_id):
# Returns entire page worth of HTML
items = Item.objects.all() # 1000+ items
return render(request, 'all_items.html', {'items': items})Result: Slow responses; large payload
✅ Solution:
def update_item(request, item_id):
# Return only the updated item
item = Item.objects.get(id=item_id)
return render(request, 'partials/item.html', {'item': item})❌ Problem:
def get_users(request):
users = User.objects.all() # Query 1
# Template loops and accesses user.profile (Query 2, 3, 4...)
return render(request, 'users.html', {'users': users})✅ Solution:
def get_users(request):
users = User.objects.select_related('profile').all()
return render(request, 'users.html', {'users': users})❌ Problem:
<div hx-get="/all-items" hx-trigger="load">
<!-- Returns 10,000 items at once -->
</div>✅ Solution:
from django.core.paginator import Paginator
def get_items(request):
page = request.GET.get('page', 1)
items = Item.objects.all()
paginator = Paginator(items, 20)
return render(request, 'items.html', {
'items': paginator.get_page(page),
'page_obj': paginator.get_page(page)
})❌ Problem:
<!-- Polls every second -->
<div hx-get="/status" hx-trigger="every 1s"></div>Result: Excessive server load
✅ Solution:
<!-- Reasonable polling interval -->
<div hx-get="/status" hx-trigger="every 5s"></div>
<!-- Or use Server-Sent Events (SSE) -->
<div hx-ext="sse" sse-connect="/status-stream">Problem: HTMX errors don't always show up in console
Solution: Enable logging
// In browser console or script tag
htmx.logAll();Add global event listeners:
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX Error:', evt.detail);
alert('An error occurred: ' + evt.detail.xhr.status);
});Problem: Need to see what HTML the server returned
Solution:
- Open DevTools Network tab
- Find the request
- Click on it
- View "Response" tab
Or add response logging:
document.body.addEventListener('htmx:afterRequest', function(evt) {
console.log('Response:', evt.detail.xhr.responseText);
});Problem: Element not found by selector
Solution: Add debug events
document.body.addEventListener('htmx:targetError', function(evt) {
console.error('Target not found:', evt.detail);
});Problem: Making requests to different ports/domains
Solution: Configure Django CORS
# Install: pip install django-cors-headers
INSTALLED_APPS = [
'corsheaders',
# ...
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
# ...
]
# Development only!
CORS_ALLOW_ALL_ORIGINS = TrueCreate reusable partial templates:
templates/
├── pages/
│ └── users.html
└── partials/
├── user_list.html
├── user_item.html
└── user_form.html
<!-- Pattern: {entity}_{action}_{tech} -->
<!-- jQuery endpoints -->
/api/users/search/ → users_search_ajax
/api/users/1/ → user_detail_ajax
<!-- HTMX endpoints -->
/htmx/users/search/ → users_search_htmx
/htmx/users/1/ → user_detail_htmxclass MyViewTests(TestCase):
def test_full_page_request(self):
response = self.client.get('/users/')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'full_page.html')
def test_htmx_request(self):
response = self.client.get('/users/',
HTTP_HX_REQUEST='true')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'partials/users.html')def my_view(request):
response = render(request, 'partial.html')
# Trigger client-side event
response['HX-Trigger'] = 'itemUpdated'
# Redirect after action
response['HX-Redirect'] = '/success/'
# Replace URL in browser
response['HX-Replace-Url'] = '/new-url'
return response<!--
PATTERN: Infinite Scroll
TRIGGER: revealed
TARGET: self (outerHTML)
ENDPOINT: /products/?page=N
SERVER: Returns products + next trigger
-->
<div hx-get="/products/?page=2"
hx-trigger="revealed"
hx-swap="outerHTML">
Loading...
</div><!-- Works without JavaScript -->
<form action="/submit/" method="post"
hx-post="/submit/"
hx-target="#result">
{% csrf_token %}
<input name="email" type="email" required>
<button type="submit">Submit</button>
</form>def my_view(request):
try:
# Your logic here
return render(request, 'success.html')
except ValidationError as e:
return render(request, 'error.html',
{'error': str(e)}, status=400)
except Exception as e:
logger.error(f"Error in my_view: {e}")
return render(request, 'error.html',
{'error': 'An unexpected error occurred'},
status=500)- Is
htmx.jsloaded? Check network tab - Is the endpoint returning HTML, not JSON?
- Is the HTTP status code correct?
- Is the CSRF token included (for POST/PUT/DELETE)?
- Is the target selector correct? (Don't forget
#for IDs) - Do form fields have
nameattributes? - Is the swap strategy appropriate?
- Are you using the right HTTP method?
- Check browser console for errors
- Check network tab for request/response
- Try
htmx.logAll()in console - Are there any middleware interfering?
- Is the response being cached inappropriately?
The key to avoiding HTMX gotchas is remembering:
- Return HTML, not JSON
- Use correct status codes
- Include CSRF tokens
- Use proper selectors
- Add name attributes to form fields
- Choose appropriate swap strategies
- Handle loading states
- Keep responses small
- Test thoroughly
- Enable logging during development
Most issues stem from thinking in jQuery/SPA patterns rather than hypermedia patterns. Once you internalize "server returns HTML" and "state lives on server," most problems disappear.