The SPA Dilemma: Django vs. JavaScript Frameworks
In the world of web development, building rich, interactive applications using JavaScript frameworks like React or Vue has become increasingly common. Many new projects begin with choosing a front-end framework. Developers accustomed to these frameworks might find Django somewhat old-fashioned. However, for those of us familiar with Django from before the rise of modern JavaScript, learning React, Vue, or Angular from scratch can feel like a tedious hurdle.
If you're one such developer, you might have wondered if a front-end framework is truly necessary just to build a single-page application (SPA). After all, combining Django's templating engine with CSS, vanilla JavaScript, and AJAX can get the job done. Sure, the code might not be as polished and structured as with a modern front-end framework. But with a few more elements to enhance its sophistication, Django could achieve the same goals—comfortably and with well-organized code. This is where HTMX comes in.
HTMX is gaining popularity among Django developers as a tool for creating SPA-like experiences without embracing a full JavaScript framework. Essentially, HTMX acts as a drop-in replacement for AJAX, allowing you to build dynamic and interactive interfaces while staying within Django. This empowers you to deliver modern web experiences to users without the complexities of newer frameworks.
In this post, I'll show you how to use Django, HTMX, and Hyperscript to create a modal window—a common component in modern web applications. This combination can help you level up your Django projects without needing to write complex JavaScript code or dive into a full front-end framework. Let's get started!
Introducing HTMX
HTMX is a JavaScript library that allows you to create dynamic events, specifically asynchronous communication, simply by adding attributes to your HTML, without writing JavaScript. Unlike traditional AJAX, which returns JSON data, HTMX handles asynchronous server communication by returning HTML itself. This means you can directly update portions of your page with server-rendered HTML. This approach empowers developers to deliver the interactive functionality users expect from modern web applications while maintaining a cleaner and more understandable codebase.
Application Overview
Here's how the application works:
- Clicking the button on the home page displays a modal window.
- Enter your email address and comment in the modal window, and then click the "Submit" button. This sends the data to the backend. After the backend validates the data, it's saved to the database and simultaneously displayed in the list on the home page.
Note that this application is aimed at creating a modal using htmx, so email validation is minimal. The comment input field has a character count. The view handles character count validation.
Development Process
The application is implemented in the following sequence:
1. Setting up the Django Project
2. Integrating Hyperscript with HTMX and base.html
3. Defining the Data Model (models.py)
4. Creating the Django Form (forms.py)
5. Registering Models in the Admin Interface (admin.py)
6. Implementing the HomeView (views.py)
7. Designing the Home Page Template (home.html)
8. Configuring URL Routing (urls.py)
9. Creating the Modal View (views.py)
10. Designing the Modal Template (modal.html)
11. Creating the Comment List Template (comment-list.html) and Updating home.html
12. Styling the Modal with CSS
Django packages
The following packages are used in this project:
While there's a package called django-htmx
that enhances HTMX functionality in Django, we won't be using it for this tutorial.
Django
psycopg2-binary
django-environ
django-widget-tweaks
1. Setting up the Django Project
To get started, set up Django as usual. If you're using Docker, follow the instructions in this article for setup guidance.
After completing the basic setup, create your first application and a template view. Then, display "Hello world". If you're following the steps in the "Basic setup for running Django with Docker" blog post, move on to step 9: "Add minimal code to urls.py and views.py to display hello world".
Let me show you the file structure for this project in advance. The project name is htmxui
, and the app name is uiapp
. Note that .dbdata
folder won't be generated if you're not using Docker.
Now, we'll be using django-widget-tweaks
for this application.. Add widget_tweaks
to INSTALLED_APPS
in settings.py
:
INSTALLED_APPS = [
...
'widget_tweaks',
...
]
2. Integrating Hyperscript with HTMX and base.html
To set up HTMX and Hyperscript, create a static folder and place the HTMX code into it. Although HTMX can be implemented with a CDN, this approach raises security concerns. Therefore, we'll include the actual code directly in the project.
To do this, you can copy the HTMX code from this URL. However, before placing the code in the static folder, you need to add the static file configuration to settings.py
. Add the following code to settings.py
:
STATIC_URL = '/static/'
# add
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
Next, create a static
folder at the root of your project and add a file named htmx.min.js
.
Then paste the HTMX code obtained from the above site into htmx.min.js
.
Afterwards, open base.html
and add the path to the HTMX script and the hyperscript CDN.
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="{% static 'css/custom-style.css' %}"/>
<title>Document</title>
</head>
<body>
<section class="site-root">
{% block contents %}
{% endblock %}
</section>
<script src="{% static 'htmx.min.js' %}" defer/>
<script src="https://unpkg.com/[email protected]"></script>
</body>
</html>
3.Defining the Data Model (models.py)
In this section, we will create the basic models for the application.
We will include three fields: EmailField
, TextField
, and DatetimeField
. Please add the following code:
from django.db import models
class Comment(models.Model):
email = models.EmailField(max_length=70)
comment = models.TextField(max_length=10)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return 'ID: {}'.format(self.id)
4.Creating the Django Form (forms.py)
Create forms.py
and add the following code:
from django import forms
from uiapp.models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model=Comment
fields = ['email', 'comment']
widgets = {
'comment': forms.Textarea(attrs={'rows':4, 'cols':15}),
}
5. Registering Models in the Admin Interface (admin.py)
Create admin.py
. Add the accompanying code:
from django.contrib import admin
from uiapp.models import Comment
class CommentAdmin(admin.ModelAdmin):
list_display = ('id', 'email')
admin.site.register(Comment, CommentAdmin)
6. Implementing the HomeView (views.py)
To create the actual home page, you need to create a HomeView
.
Add the accompanying code:
from django.views.generic import ListView
from htmx_app.models import Comment
class HomeView(ListView):
template_name = 'uiapp/home.html'
model = Comment
7. Designing the Home Page Template (home.html)
Create a template that corresponds to the HomeView
described above.
To do so, create a template folder at the project's root and create each file with the following structure.
Put the following code in home.html
.
{% extends 'base.html' %}
{% block contents %}
<main class="main">
<h1>Modal with HTMX and HyperScript</h1>
<div class="main__button">
<button hx-get="{% url 'uiapp:display-modal' %}" hx-target="body" hx-swap="beforeend" class="main__open-button">Open a Modal</button>
</div>
</main>
{% endblock %}
In the code above, the following code is HTMX code:
<button hx-get="{% url 'uiapp:display-modal' %}" hx-target="body" hx-swap="beforeend" class="main__open-button">Open a Modal</button>
The hx-get
attribute makes an asynchronous request to the display-modal
view. The result of this request is returned in the body
of the HTML document, just before the end of the body
element.
8. Configuring URL Routing (urls.py)
In this application, creating urls.py
on the app side is slightly different from previous apps.
The URL pattern is divided into two parts: urlpatterns and htmx_urlpatterns.
Although it would work without separating them, separating the views used in HTMX makes the code more organized.
from django.urls import path
from htmx_app.views import HomeView, modal delete_comment
app_name = 'uiapp'
urlpatterns = [
path('', HomeView.as_view(), name="home"),
]
htmx_urlpatterns = [
path('modal/', modal, name="display-modal"),
]
urlpatterns += htmx_urlpatterns
9. Creating the Modal View (views.py)
Let's add the following code to views.py
.
from django.shortcuts import render
def modal(request):
return render(request, 'uiapp/modal.html')
10. Designing the Modal Template (modal.html)
Let's create a modal.html
file.
This code includes a section that uses htmx and another section that uses hyperscript. Additionally, a hyperscript CDN is added at the end of the code.
It's hard to understand when htmx and hyperscript are mixed, so I'll explain each separately.
{% load widget_tweaks %}
<div id="modal" _="on closeModal add .closing then wait for animationend then remove me">
<div class="modal-underlay" _="on click trigger closeModal"></div>
<div class="modal-content">
<h1>Modal Dialog</h1>
<form method="POST" action="{% url 'uiapp:display-modal' %}" class="dialog">
{% csrf_token %}
<div id="email-error"></div>
<label>{{ form.email.label_tag }}</label>
{% render_field form.email class="form__email" %}
<label>{{ form.comment.label_tag }}</label>
{% render_field form.comment hx-post="/check_comment/" hx-swap="outerHTML" hx-trigger="keyup" hx-target="#modal_field" class="form__comment" %}
<div id="modal_field">Count:</div>
<div class="form__button">
<input type="button" value="Cancel" _="on click trigger closeModal"></input>
<input type="button" value="Submit" hx-post="{% url 'uiapp:display-modal' %}" hx-trigger="click" hx-target="#email-error" hx-swap="innerHTML"></input>
</div>
</form>
</div>
</div>
<script src="https://unpkg.com/[email protected]"></script>
10-1. Hyperscript
The most eye-catching part of the above code is the code starting with an underscore (_). At first, I thought this was just a comment, but in fact, this is hyperscript.
For example, notice the Cancel button and the id="modal" tag at the top of the page.
_="on click trigger closeModal"
This code means that when the Cancel button is clicked, an event called "closeModal" is triggered.
Then, when the closeModal
event is triggered, the class .closing
is added because the closeModal
event is being listened to in the following HTML tag.
<div id="modal" _="on closeModal add .closing then wait for animationend then remove me">
...
After that, the modal is removed after the CSS animation is completed.
Writing this in JavaScript would be quite a painstaking task, but surprisingly, hyperscript can be written quickly as if it were handling natural language.
10-2. htmx
In the code above, the following htmx code appears. I will explain this part.
{% render_field form.comment hx-post="/check_comment/" hx-swap="outerHTML" hx-trigger="keyup" hx-target="#modal_field" class="form__comment" %}
The code above has the following meaning:
hx-trigger="keyup"
: This triggers the event each time a key is pressed and released.hx-post="/check_comment/"
: This sends data via POST to thecheck_comment
view.hx-target="#modal_field"
: This targets the#modal_field
.hx-swap="outerHTML"
: This replaces the entire specified HTML.
To add the CSS related to modal, create a file named static/css/custom-style.css
and add the following code.
(This CSS code is partly based on the code in the official htmx document.)
/*************
MODAL DIALOG and FORM
**************/
*{
padding:0;
margin:0;
box-sizing:border-box;
}
:root{
--main-font-family:'Roboto Condensed', sans-serif;
--main-bg-color:#262626;
--main-color:#1FC742;
}
body{
position: absolute;
max-width: 100%;
top: 0;
bottom: 0;
overflow-x: hidden;
font-family: var(--main-font-family);
background-color: var(--main-bg-color);
color: var(--main-color);
border-color:var(--main-color);
line-height: 1.5;
}
a, u {
text-decoration: none;
color: var(--main-color);
}
h1{
font-size: 3rem;
line-height: 4rem;
}
.site-root{
display: grid;
grid-template-columns: 10vw 80vw 10vw;
grid-template-areas: "left main right";
}
/*************
Main part
**************/
.main{
grid-area:main;
padding: 3rem 1rem;
display: grid;
grid-auto-rows: auto;
grid-gap:1rem
}
/*************
modal
**************/
#modal {
position: fixed;
top:0;
bottom: 0;
left:0;
right:0;
background-color:rgba(0,0,0,0.5);
z-index:1000;
display:flex;
flex-direction:column;
align-items:center;
animation-name: fadeIn;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal > .modal-underlay {
position: absolute;
z-index: -1;
top:0;
bottom:0;
left: 0;
right: 0;
}
#modal > .modal-content {
margin-top:10vh;
width:80%;
max-width:600px;
border:solid 1px var(--main-color);
border-radius:8px;
box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.3);
background-color: var(--main-bg-color);
padding:20px;
animation-name:zoomIn;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal.closing {
animation-name: fadeOut;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal.closing > .modal-content {
animation-name: zoomOut;
animation-duration:150ms;
animation-timing-function: ease;
}
@keyframes fadeIn {
0% {opacity: 0;}
100% {opacity: 1;}
}
@keyframes fadeOut {
0% {opacity: 1;}
100% {opacity: 0;}
}
@keyframes zoomIn {
0% {transform: scale(0.9);}
100% {transform: scale(1);}
}
@keyframes zoomOut {
0% {transform: scale(1);}
100% {transform: scale(0.9);}
}
10-3. Creating View
Once the frontend implementation is complete, the next step is to create views on the backend side.
Here, we will create two view functions. One is to write code that receives the POST
sent from the modal.
The other is to create a new view that checks the number of characters in the comment entered in the modal form.
First, let's add the following code to the modal
view we created earlier.
...
from django.http import HttpResponse
from htmx_app.forms import CommentForm
...
def modal(request):
# ↓↓↓ add
form = CommentForm()
if request.method == 'POST':
form=CommentForm(request.POST)
if form.is_valid():
form.save()
html = "<div id='email-error' _='on load wait 1s trigger closeModal'>Success</div>"
return HttpResponse(html, headers={'HX-Trigger': 'newList'})
return HttpResponse('no')
# ↑↑↑ add
return render(request, 'uiapp/modal.html', {'form':form}) #add
The check_comment
view is responsible for validating entered data.
If the input contains more than ten characters, an HTML code that displays an error message is returned.
If it contains ten or fewer characters, an HTML code that includes the character count is returned.
...
def check_comment(request):
comment = request.POST.get('comment')
comment_number = len(comment)
if comment_number > 9:
html = "<div id='modal_field' class='comment_error'>Count:<span id='comment-error'>The number of characters is the limit. %s</span></div>" % comment_number
return HttpResponse(html)
else:
html = "<div id='modal_field'>Count:<span id='comment-error'>%s</span></div>" % comment_number
return HttpResponse(html)
Add the corresponding URLs in urls.py
for the above view.
...
from uiapp.views import HomeView, modal, check_comment
...
htmx_urlpatterns = [
...
path('check_comment/', check_comment, name='check-comment'),
]
...
11. Creating the Comment List Template (comment-list.html) and Updating home.html
To display the data entered in the modal window, create a template called comment-list.html
in the templates/uiapp
folder.
After creating the file, add the following code to it.
{% extends 'base.html' %}
<div id="list" class="list" hx-get="{% url 'uiapp:list-comment' %}" hx-trigger="newList delay:2s from:body">
</div>
Do you remember the code headers={'HX-Trigger': 'newList'}
that appeared in the modal
view earlier?
The newList
trigger is activated here. This code means that when it receives headers={'HX-Trigger': 'newList'}
, it will send a get
request to uiapp:list-comment
after 2 seconds.
Next, create thelist_comment
view.
This view simply retrieves all the data from the model.
...
def list_comment(request):
lists = Comment.objects.all()
context = {
'lists':lists
}
return render(request, 'uiapp/comment-list.html', context)
Once the view implementation is complete, add the accompanying code to urls.py
on the application side.
...
from uiapp.views import HomeView, modal, check_comment, list_comment
...
htmx_urlpatterns = [
...
path('list_comment/', list_comment, name='list-comment'),
...
]
...
Add the new attributes to the HomeView
you created earlier as follows.
...
class HomeView(ListView):
template_name = 'uiapp/home.html'
model = Comment
context_object_name = "lists" #add
ordering = '-id' #add
...
Add the following code to comment-list.html
.
<div id="list" class="list" hx-get="{% url 'uiapp:list-comment' %}" hx-trigger="newList delay:2s from:body">
<!-- ↓↓↓ add -->
{% for list in lists|dictsortreversed:"id" %}
<div class="list__item">
<span class="list__email">{{ list.email }}</span>
<span class="list__comment">{{ list.comment }}</span>
<input type="button"
value="Delete"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'uiapp:delete-comment' pk=list.id %}"
hx-trigger="click"
hx-target="#list"
hx-swap="outerHTML"
class="list__button"
></input>
</div>
{% endfor %}
<!-- ↑↑↑ add -->
</div>
The above code displays a list of items while also adding a delete button for each item in the list.
HTMX is used to implement the delete functionality.
While the HTMX code has many attributes, the key one to focus on is hx-post
. Each item has an ID, and hx-post
calls the view corresponding to this ID.
The view deletes the data for the specified ID. Finally, the HTML tag returned from the view side replaces the #list
part set in hx-target
.
Now, let's go back to the view and create the delete_comment
view.
...
from django.shortcuts import render, get_object_or_404
...
def delete_comment(request, pk):
list_item = get_object_or_404(Comment, pk=pk)
if request.method == "POST":
list_item.delete()
lists = Comment.objects.all()
context = {
'lists':lists
}
return render(request, 'uiapp/comment-list.html', context)
The corresponding URL for this view is:
...
from uiapp.views import HomeView, modal, check_comment, list_comment, delete_comment
...
htmx_urlpatterns = [
...
path('delete_comment/<int:pk>/', delete_comment, name='delete-comment')
]
...
Finally, add an include tag to home.html so that this list appears on the home screen.
{% extends 'base.html' %}
{% block contents %}
<main class="main">
<h1>Modal with HTMX and HyperScript</h1>
<div class="main__button">
<button hx-get="{% url 'uiapp:display-modal' %}" hx-target="body" hx-swap="beforeend" class="main__open-button">Open a Modal</button>
</div>
<!-- Add -->
{% include 'uiapp/comment-list.html' %}
</main>
{% endblock %}
That's it! We have now implemented the same functionality as shown in the GIF animation at the beginning of this article.
12. Styling the Modal with CSS
If you want to achieve the same design as shown in the video at the beginning of the article, add the following CSS.
This CSS is created using the grid layout. Please note that the CSS for the modal that we previously added is also included. (Please add only the parts that show "Please Add~.")
*{
padding:0;
margin:0;
box-sizing:border-box;
}
:root{
--main-font-family:'Roboto Condensed', sans-serif;
--main-bg-color:#262626;
--main-color:#1FC742;
}
body{
position: absolute;
max-width: 100%;
top: 0;
bottom: 0;
overflow-x: hidden;
font-family: var(--main-font-family);
background-color: var(--main-bg-color);
color: var(--main-color);
border-color:var(--main-color);
line-height: 1.5;
}
a, u {
text-decoration: none;
color: var(--main-color);
}
h1{
font-size: 3rem;
line-height: 4rem;
}
.site-root{
display: grid;
grid-template-columns: 10vw 80vw 10vw;
grid-template-areas: "left main right";
}
/*************
Main part
**************/
.main{
grid-area:main;
padding: 3rem 1rem;
display: grid;
grid-auto-rows: auto;
grid-gap:1rem
}
/**************************
Please Add from here to "MODAL DIALOG and FORM".
***************************/
.main__button{
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: 2rem;
}
.main__open-button {
border: 1px solid var(--main-color);
color: var(--main-color);
background-color: var(--main-bg-color);
}
.main__open-button:hover {
cursor: pointer;
border: 1px solid var(--main-color);
color: var(--main-bg-color);
background-color: var(--main-color);
}
/*************
MODAL DIALOG and FORM
**************/
#modal {
/* Underlay covers entire screen. */
position: fixed;
top:0px;
bottom: 0px;
left:0px;
right:0px;
background-color:rgba(0,0,0,0.5);
z-index:1000;
/* Flexbox centers the .modal-content vertically and horizontally */
display:flex;
flex-direction:column;
align-items:center;
/* Animate when opening */
animation-name: fadeIn;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal > .modal-underlay {
/* underlay takes up the entire viewport. This is only
required if you want to click to dismiss the popup */
position: absolute;
z-index: -1;
top:0px;
bottom:0px;
left: 0px;
right: 0px;
}
#modal > .modal-content {
/* Position visible dialog near the top of the window */
margin-top:10vh;
/* Sizing for visible dialog */
width:80%;
max-width:600px;
/* Display properties for visible dialog*/
border:solid 1px var(--main-color);
border-radius:8px;
box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.3);
background-color: var(--main-bg-color);
padding:20px;
/* Animate when opening */
animation-name:zoomIn;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal.closing {
/* Animate when closing */
animation-name: fadeOut;
animation-duration:150ms;
animation-timing-function: ease;
}
#modal.closing > .modal-content {
/* Aniate when closing */
animation-name: zoomOut;
animation-duration:150ms;
animation-timing-function: ease;
}
@keyframes fadeIn {
0% {opacity: 0;}
100% {opacity: 1;}
}
@keyframes fadeOut {
0% {opacity: 1;}
100% {opacity: 0;}
}
@keyframes zoomIn {
0% {transform: scale(0.9);}
100% {transform: scale(1);}
}
@keyframes zoomOut {
0% {transform: scale(1);}
100% {transform: scale(0.9);}
}
/**************************
Please add below from here.
***************************/
/*************
List part
**************/
.list{
display: grid;
grid-gap:1rem
}
.list__item {
height: 4rem;
display: grid;
grid-template-columns: repeat(10, 1fr);
justify-content: flex-start;
align-items: center;
border: 1px solid;
border-radius: 1rem;
padding: 1rem;
}
.list__button{
height: 2rem;
border: 1px solid var(--main-color);
color: var(--main-color);
background-color: var(--main-bg-color);
}
.list__button:hover{
cursor: pointer;
border: 1px solid var(--main-color);
color: var(--main-bg-color);
background-color: var(--main-color);
}
/************
Form part
************/
.dialog{
display: grid;
grid-auto-columns: auto;
grid-auto-rows:auto;
grid-gap: 0.5rem;
}
.form__button{
width: auto;
height: 2rem;
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.form__button input{
border: 1px solid var(--main-color);
color: var(--main-color);
background-color: var(--main-bg-color);
}
.form__button input:hover{
cursor: pointer;
border: 1px solid var(--main-color);
color: var(--main-bg-color);
background-color: var(--main-color);
}
.form__email{
height: 2rem;
padding: 0 0.5rem;
background-color: var(--main-bg-color);
color:var(--main-color);
border: 1px solid var(--main-color);
}
.form__email:focus{
background-color: var(--main-bg-color);
outline: none;
border: 2px solid var(--main-color);
}
.form__comment{
height: 8rem;
padding: 0.5rem;
color: var(--main-color);
background-color: var(--main-bg-color);
border: 1px solid var(--main-color);
}
.form__comment:focus{
outline: none;
border: 2px solid var(--main-color);
}
.comment_error{
color: red;
}
Conclusion
This tutorial has been fairly lengthy, but it only scratches the surface of what's possible with HTMX. As you've seen, HTMX simplifies creating dynamic updates in Django projects, like the modal window we built, without requiring extensive JavaScript. It's a powerful tool that can significantly streamline your front-end development workflow.
I encourage you to explore HTMX further. The official documentation is a great resource, and you could try building other interactive components, like dynamic forms or live search, to solidify your understanding.
The GitHub repository for this project is available here. Happy coding!