djust docs
Browse documentation
beginner

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:

  1. Browser default submit is prevented
  2. All fields are collected via FormData
  3. Data is sent to the server as event params: {name: "...", email: "..."}
  4. If using FormMixin, submit_form() validates with your Django Form
  5. form_valid() or form_invalid() is called
  6. 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 as field, not field_name — the client hardcodes that key and reads it from data-field, the element's name, then its id. field_name is accepted as a backwards-compatible alias, so def validate_field(self, field="", value=None, **kwargs) is the shape to write. This guide previously documented field_name as 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:

AttributeTypePurpose
form_datadictCurrent field values (keyed by field name)
field_errorsdictPer-field errors: {field: [errors]}
form_errorsdict | ErrorListNon-field errors from clean(): an empty dict until an invalid submit, then a list
is_validboolResult of last submit_form()
form_instanceFormCurrent 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 CaseApproach
Search / filters / togglesdj-model
Data entry with validationdj-submit + FormMixin
Multi-field forms with savedj-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 a RadioSelect.
  • 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 %} inside dj-submit forms (needed for the native, CSRF-protected JSON event fallback).
  • Use dj-change="validate_field" on fields for instant feedback before submission.
  • Set _model_instance before super().mount() when editing existing records.
  • Keep form_data keys consistent. FormMixin initializes all field keys in mount(). Don't add or remove keys -- it breaks VDOM diffing.
  • Use form_errors for cross-field validation. Errors from clean() go to form_errors, per-field errors go to field_errors.

Markdown editor

See the Markdown Editor guide for optional Visual/Markdown editing, native form integration, asset loading, theme variables and editing limitations.