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.
Strict parameter policy. Under the opt-in strict parameter policy (
@event_handler(parameter_policy="strict"), ADR-036), the browser sends a generated value only if the handler declares a parameter with that name, or has a**catch-all. Generated values arevalueandfieldfordj-input/dj-change, the form fields fordj-submit, andkey/codefor keyboard events.dj-value-*arguments are always sent, and one that reuses a generated name is rejected._targetis never sent: usefieldor adj-value-*argument. Legacy handlers (the default) receive every value as before. A strictdj-submithandler therefore gets only the fields it names, or all of them through**form_data.
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() |
success_message | str | Success message your view sets (empty by default) |
error_message | str | Error message your view sets (empty by default) |
form_choices | dict | (value, label) pairs per choice field, keyed by field name |
The Django form instance is available in Python as self.form_instance; it is not in the template context.
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.
Editing one record with ModelFormMixin
Available from djust 1.3 (not in the 1.3.0rc1 pre-release).
djust.forms.ModelFormMixin edits one existing record with no mount()
override. The URL route supplies the record's pk (or slug). djust looks
the record up and checks access before it builds the form, and checks again
on every event:
from django import forms
from djust import LiveView
from djust.forms import ModelFormMixin
from .models import Article
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "body"]
class ArticleEditView(ModelFormMixin[Article], LiveView):
template_name = "article_edit.html"
model = Article
form_class = ArticleForm
login_required = True
def get_queryset(self):
# Only the signed-in user's articles can be opened.
return super().get_queryset().filter(author=self.request.user)
def form_valid(self, form):
self.object = form.save()
self.success_message = "Saved!"
Route it with the record's id:
# urls.py
path("articles/<int:pk>/edit/", ArticleEditView.as_view())
The template shows the record as object and the form as usual:
<div dj-root>
<h1>Editing {{ object.title }}</h1>
<form dj-submit="submit_form">
{% csrf_token %}
<input name="title" value="{{ form_data.title }}" dj-change="validate_field">
{% if field_errors.title %}<span>{{ field_errors.title.0 }}</span>{% endif %}
<textarea name="body">{{ form_data.body }}</textarea>
<button type="submit">Save</button>
</form>
{% if success_message %}<p>{{ success_message }}</p>{% endif %}
</div>
What happens:
- The record comes from the route, never from the browser. The lookup
uses
self.kwargs: the route's URL kwargs, resolved on the server for every transport.pk_url_kwarg,slug_url_kwarg,slug_fieldandquery_pk_and_slugwork as in Django'sUpdateView. Navigating to another record's URL without a page load (adj-patchlink, back/forward, orlive_patch(path=...)) remounts the view there, so the form always edits the record in the address bar. - Access is checked before the form exists. A record missing from
get_queryset()and one refused byhas_object_permission(request, obj)get the same "Access denied" response: HTTP 403, or apermission_deniederror on a live connection. No form is built and no hook runs, so a user cannot tell a missing record from a forbidden one. - Every event checks again. If access is revoked or the record is deleted while the page is open, the next event is denied before your code runs.
self.objectis the checked record for the current request or event. It renders asobject; setcontext_object_name = "article"to add a second name. It is never saved to the session: only the route identifies the record.self.object = form.save()keeps it current. Assigning a different record raisesValueError; link to that record's URL instead.- You decide what saving means.
form_valid()runs only for a valid form, and djust never saves on its own. - Scope the records. Override
get_queryset()orhas_object_permission(). With neither, any signed-in user who can open the view can edit any record by changing the id in the URL; system checkdjust.S013warns about it.
form_class must be a ModelForm that lists its editable fields. Keep
fields such as author out of it, so a user cannot reassign them. For a
create form, keep using FormMixin with a ModelForm.
Moving from _model_instance
The pattern in Editing Existing Records still
works. To move a view to ModelFormMixin:
- Replace
FormMixinwithModelFormMixin[YourModel]and setmodel. - Delete the
mount()code that loads_model_instance. The route'spkselects the record. - Move any ownership filter into
get_queryset(), or a per-record rule intohas_object_permission(). - In
form_valid(), assign the saved record withself.object = form.save().
Don't set _model_instance on a ModelFormMixin view: that is a
configuration error.
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, or from djust 1.3 useModelFormMixin. - 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.