Forms
Handle form submissions, validation, and user input in djust LiveViews
On this page
djust handles forms over WebSocket. No page reloads, no JavaScript, no API layer. You write a Python handler, add dj-submit to your <form>, and it works.
The Simplest Form
from djust import LiveView
from djust.decorators import event_handler
class TodoView(LiveView):
template_name = 'todos.html'
def mount(self, request, **kwargs):
self.items = []
@event_handler()
def add_item(self, title="", **kwargs):
if title.strip():
self.items.append(title.strip())
<div dj-root>
<form dj-submit="add_item">
<input type="text" name="title" placeholder="New item">
<button type="submit">Add</button>
</form>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
That's it. dj-submit prevents the default submit, collects all form fields via FormData, and sends them to your handler as keyword arguments. The view re-renders automatically.
HTTP fallback does not require a second view
Use semantic HTML forms such as <form dj-submit="submit_form"> with Django
Form classes and FormMixin. Validation and form state do not require a separate
traditional Django POST view: FormMixin supplies the decorated submit_form
event handler.
When WebSocket is unavailable, the djust browser client can send a
CSRF-protected JSON event to the current LiveView URL using fetch. The inherited
LiveView.post() dispatches the decorated event handler and returns JSON. Include
{% csrf_token %} inside the form for this native HTTP event transport. No
application post() override or duplicate form endpoint is needed.
This transport still requires JavaScript. Supporting a browser with JavaScript
disabled through ordinary URL-encoded or multipart form submissions is a separate,
optional product requirement. If you deliberately support both protocols, ensure
JSON events still reach super().post(): an override that returns HTML for them
breaks the client's JSON response handling.
File uploads have their own transport and lifecycle; JSON event fallback does not by itself provide every binary-upload capability. See the upload guide for the transport supported by your upload configuration.
Adding Validation with Django Forms
When you need real validation -- required fields, email formats, custom rules -- use Django's forms system with FormMixin:
from django import forms
from djust import LiveView
from djust.forms import FormMixin
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
class ContactView(FormMixin, LiveView):
template_name = 'contact.html'
form_class = ContactForm
def form_valid(self, form):
send_email(form.cleaned_data)
self.success_message = "Sent!"
def form_invalid(self, form):
self.error_message = "Please fix the errors below."
FormMixin gives you submit_form() (validates the form), validate_field() (validates one field on change), reset_form() (clears everything), and form_valid()/form_invalid() hooks.
The Template
Write your HTML however you want. No CSS framework required:
<div dj-root>
{% if success_message %}<p>{{ success_message }}</p>{% endif %}
{% if error_message %}<p>{{ error_message }}</p>{% endif %}
<form dj-submit="submit_form">
{% csrf_token %}
<label>Name</label>
<input type="text" name="name" value="{{ form_data.name }}"
dj-change="validate_field">
{% if field_errors.name %}<span>{{ field_errors.name.0 }}</span>{% endif %}
<label>Email</label>
<input type="email" name="email" value="{{ form_data.email }}"
dj-change="validate_field">
{% if field_errors.email %}<span>{{ field_errors.email.0 }}</span>{% endif %}
<label>Message</label>
<textarea name="message"
dj-change="validate_field">{{ form_data.message }}</textarea>
{% if field_errors.message %}<span>{{ field_errors.message.0 }}</span>{% endif %}
<button type="submit">Send</button>
</form>
</div>
dj-change="validate_field" validates that field when the user tabs away. Errors appear instantly without a full form submission.
Or Skip the Manual HTML
If you don't want to write each field by hand, use the view's as_live() method. It belongs to FormMixin (not to the Django form), so render it in get_context_data and put the HTML in the context:
from django.utils.safestring import mark_safe
class ContactView(FormMixin, LiveView):
form_class = ContactForm
template_name = "contact.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form_html"] = mark_safe(self.as_live())
return context
<form dj-submit="submit_form">
{% csrf_token %}
{{ form_html }}
<button type="submit">Send</button>
</form>
This auto-renders all fields with labels, error display, and validation bindings. Configure the output style in settings:
LIVEVIEW_CONFIG = {"css_framework": "bootstrap5"} # or "bootstrap4", "tailwind", "plain", None
You can also render a single field with self.as_live_field("email") the same way.
How It Works
When a dj-submit form is submitted:
- Browser default submit is prevented
- All fields are collected via
FormData - Data is sent to the server as event params:
{name: "...", email: "..."} - If using FormMixin,
submit_form()validates with your Django Form form_valid()orform_invalid()is called- The view re-renders with updated state
Real-Time Validation
dj-change fires on the change event (blur for text inputs, selection for dropdowns/checkboxes):
<input type="email" name="email" value="{{ form_data.email }}"
dj-change="validate_field">
When the user leaves the field, djust sends validate_field(field="email", value="user@example.com"). The field is validated against the Django Form, and errors update instantly.
Overriding
validate_field? The field name arrives asfield, notfield_name— the client hardcodes that key and reads it fromdata-field, the element'sname, then itsid.field_nameis accepted as a backwards-compatible alias, sodef validate_field(self, field="", value=None, **kwargs)is the shape to write. This guide previously documentedfield_nameas the parameter, which produced overrides that ran on every keystroke and silently received nothing (#2137).
For validation on focus loss specifically, use dj-blur:
<input type="text" name="username" value="{{ form_data.username }}"
dj-blur="validate_field">
FormMixin State
FormMixin initializes these in mount(), all available in your template:
| Attribute | Type | Purpose |
|---|---|---|
form_data | dict | Current field values (keyed by field name) |
field_errors | dict | Per-field errors: {field: [errors]} |
form_errors | dict | ErrorList | Non-field errors from clean(): an empty dict until an invalid submit, then a list |
is_valid | bool | Result of last submit_form() |
form_instance | Form | Current Django Form instance |
Displaying Errors
Per-field errors:
{% if field_errors.email %}
{% for error in field_errors.email %}
<span>{{ error }}</span>
{% endfor %}
{% endif %}
Non-field errors (from your form's clean() method):
{% if form_errors %}
{% for error in form_errors %}
<p>{{ error }}</p>
{% endfor %}
{% endif %}
Style these however fits your app. djust has no opinion on your CSS.
Editing Existing Records
For ModelForms, set _model_instance before super().mount():
from django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'body', 'category']
class ArticleEditView(FormMixin, LiveView):
template_name = 'article_form.html'
form_class = ArticleForm
def mount(self, request, pk=None, **kwargs):
if pk:
self._model_instance = Article.objects.get(pk=pk)
super().mount(request, **kwargs)
def form_valid(self, form):
form.save()
self.success_message = "Saved!"
FormMixin populates form_data from the instance automatically. The template is the same pattern -- value="{{ form_data.title }}" etc.
Form Reset
Clear the form back to its initial state:
@event_handler()
def submit_and_reset(self, **kwargs):
self.submit_form(**kwargs)
if self.is_valid:
self.reset_form()
Calling reset_form() inside form_valid() works too: submit_form() does not
copy the submitted values back over a reset. reset_form() also clears
success_message, so set the message after the reset:
def form_valid(self, form):
save(form.cleaned_data)
self.reset_form()
self.success_message = "Saved!"
Or let users reset manually. reset_form is an @event_handler, so a template can call it directly:
<button type="button" dj-click="reset_form">Clear</button>
Confirmation Dialogs
Add dj-confirm to show a browser confirmation before the action fires:
<form dj-submit="delete_account"
dj-confirm="This will permanently delete your account. Are you sure?">
<button type="submit">Delete Account</button>
</form>
dj-model vs dj-submit
dj-model syncs a field value to a Python attribute on every change. dj-submit collects all fields and sends them on submit.
| Use Case | Approach |
|---|---|
| Search / filters / toggles | dj-model |
| Data entry with validation | dj-submit + FormMixin |
| Multi-field forms with save | dj-submit |
Example with dj-model for a live filter:
class FilterView(LiveView):
template_name = 'filter.html'
def mount(self, request, **kwargs):
self.search = ""
self.category = "all"
def get_context_data(self, **kwargs):
qs = Product.objects.all()
if self.search:
qs = qs.filter(name__icontains=self.search)
if self.category != "all":
qs = qs.filter(category=self.category)
return {'products': qs}
<input type="text" dj-model.debounce-300="search" placeholder="Search...">
<select dj-model="category">
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
See the Model Binding guide for details.
Inline Radio Buttons
Django's default RadioSelect renders each choice on its own line (vertical list). For segmented controls, filter pills, toolbar choices, or short Yes/No fields, you usually want them inline. djust ships a small opt-in CSS helper that does this without you writing any new Python:
class FilterForm(forms.Form):
status = forms.ChoiceField(
widget=forms.RadioSelect(attrs={"data-dj-inline": "true"}),
choices=[("all", "All"), ("open", "Open"), ("closed", "Closed")],
)
In your base template, after djust's client.js, link the form helper stylesheet:
{% load static %}
<link rel="stylesheet" href="{% static 'djust/djust-forms.css' %}">
That's it. Django's widget mechanics put attrs={...} onto each <input type="radio">, so the rendered HTML (Django 4.0+) looks like:
<div id="id_status">
<div><label><input type="radio" name="status" value="all" data-dj-inline="true"> All</label></div>
<div><label><input type="radio" name="status" value="open" data-dj-inline="true"> Open</label></div>
...
</div>
The attribute sits on each <input>, never on the wrapper. The bundled CSS uses the :has() parent selector to walk up from the marked input and lay out the containing <div> (or <ul>, for templates that render a list) as inline-flex. Result: full keyboard navigation, native focus ring preserved, no extra Python required. Browser support: Chromium 105+, Safari 15.4+, Firefox 121+ — all stable since 2023.
Why a data- attribute and not a custom widget?
Three reasons:
- Zero new Python.
RadioSelect(attrs={...})is already supported by Django; we just document the attribute name. - Composes with anything. Works with
forms.Form,LiveViewForm, third-party form libraries, ModelForms, Django admin — anything that renders aRadioSelect. - Skip-able. Don't want our CSS? Don't link the file. Want different styling? Write your own rule on
[data-dj-inline]— the contract is the attribute name, not the visual treatment.
Customizing the visual treatment
Override the bundled rules in your own stylesheet (loaded after djust-forms.css):
:is(ul, div):has(> :is(li, div) > label > input[data-dj-inline]) {
/* Replace the default flex with a CSS Grid for fixed columns: */
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Or turn it into a segmented-control: */
label:has(> input[data-dj-inline]) {
border: 1px solid #ccc;
padding: 0.4em 0.8em;
border-radius: 4px;
}
label:has(> input[data-dj-inline]:checked) {
background: #1e88e5;
color: white;
}
The [data-dj-inline] attribute on the radio input is the documented contract. The default styling is a starting point.
Multiple inline fields on one form
Just add the attribute to each field's widget:
class FilterForm(forms.Form):
status = forms.ChoiceField(
widget=forms.RadioSelect(attrs={"data-dj-inline": "true"}),
choices=STATUS_CHOICES,
)
priority = forms.ChoiceField(
widget=forms.RadioSelect(attrs={"data-dj-inline": "true"}),
choices=PRIORITY_CHOICES,
)
Each radio input carries the attribute; the CSS finds its wrapper with :has(). No form-level config, no class hierarchy.
Tips
- Always include
{% csrf_token %}insidedj-submitforms (needed for the native, CSRF-protected JSON event fallback). - Use
dj-change="validate_field"on fields for instant feedback before submission. - Set
_model_instancebeforesuper().mount()when editing existing records. - Keep
form_datakeys consistent. FormMixin initializes all field keys inmount(). Don't add or remove keys -- it breaks VDOM diffing. - Use
form_errorsfor cross-field validation. Errors fromclean()go toform_errors, per-field errors go tofield_errors.
Markdown editor
See the Markdown Editor guide for optional Visual/Markdown editing, native form integration, asset loading, theme variables and editing limitations.