Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

6 min read

Tutorial: Real-time form validation with FormMixin

Forms are the highest-friction surface in any app. Every extra re-render, every laggy error message, every "click submit and find out" round-trip is a cost the user pays. The dream UX is: field-level validation that fires as the user types, errors that appear inline next to the bad input, the submit button enabled only when the whole form is valid, and a server-side guarantee that the data is correct before persisting.

djust gets you there with FormMixin — wraps a normal Django Form so its validation runs over the WebSocket on every field change. No JS validation library duplicating server rules. No "my client and server validation drifted." One Form class is the source of truth.

By the end of this tutorial you'll have:

  • A sign-up form with username, email, password fields.
  • Username availability checked as the user types (debounced 300 ms) — red "taken" / green "available" inline.
  • Password strength rated as the user types (uses Django's built-in password_validation).
  • Email format validated on blur, not on every keystroke (avoids "alice@" looking invalid mid-typing).
  • The submit button disabled until the form is fully valid.
You'll learnDocumented in
FormMixin + form_classForms
validate_field() for per-field handlersForms
dj-input vs dj-change (keystroke vs blur)Forms
dj-debounce to throttle DB-touching validatorsThis tutorial
Async validators that hit the DB without blockingThis tutorial

Prerequisites: Quickstart, familiarity with Django Forms (docs) and the search-as-you-type tutorial (sets up the debouncing vocabulary).


Step 1 — The Django form (your single source of truth)

# myapp/forms.py
from django import forms
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError


class SignUpForm(forms.Form):
    username = forms.CharField(min_length=3, max_length=30)
    email    = forms.EmailField()
    password = forms.CharField(widget=forms.PasswordInput, min_length=8)

    def clean_username(self):
        username = self.cleaned_data["username"]
        if not username.isalnum():
            raise ValidationError("Username must be letters and digits only.")
        if User.objects.filter(username__iexact=username).exists():
            raise ValidationError("That username is taken.")
        return username

    def clean_email(self):
        email = self.cleaned_data["email"]
        if User.objects.filter(email__iexact=email).exists():
            raise ValidationError("An account with that email already exists.")
        return email

    def clean_password(self):
        password = self.cleaned_data["password"]
        try:
            validate_password(password)  # Django's configured validators
        except ValidationError as e:
            raise ValidationError(list(e.messages))
        return password

Three field validators, each a regular Django clean_<field> method. The same code runs on per-keystroke validation, on blur, and on full-form submit. That's the contract: write the validation once; the framework picks the right moment to run it.


Step 2 — The view, with FormMixin

# myapp/views.py
from djust import LiveView, action, state
from djust.forms import FormMixin

from .forms import SignUpForm


class SignUpView(FormMixin, LiveView):
    template_name = "signup.html"
    form_class = SignUpForm

    success_message = state("")

    @action
    def submit(self, **kwargs):
        # FormMixin already populated self.form with the submitted
        # data. self.form.is_valid() runs the same clean_* methods
        # as the per-field validators — defense in depth.
        if not self.form.is_valid():
            return  # template re-renders with errors
        data = self.form.cleaned_data
        # Persist:
        from django.contrib.auth.models import User
        User.objects.create_user(
            username=data["username"],
            email=data["email"],
            password=data["password"],
        )
        self.success_message = (
            f"Welcome, {data['username']}! Check {data['email']} for confirmation."
        )

That's the whole view. FormMixin sets up four pieces of magic:

  1. self.form — the form instance, repopulated on each render from the latest field values.
  2. self.form.errors — keyed by field name; surfaced in the template.
  3. self.form.is_valid — auto-called when the user types in any field (per-field) and on submit (whole form).
  4. The validate_field event handler is wired automatically — you don't write it.

Step 3 — The template

<!-- myapp/templates/signup.html -->
<form dj-submit="submit" novalidate>
  <fieldset>
    <legend>Create an account</legend>

    {# username — validates on every keystroke (debounced 300ms) #}
    <label class="field {% if form.username.errors %}is-invalid{% elif form.username.value %}is-valid{% endif %}">
      Username
      <input
        type="text"
        name="username"
        value="{{ form.username.value|default:'' }}"
        dj-input="validate_field"
        dj-debounce="300"
        dj-payload-field="username"
        autocomplete="username"
        required
      />
      {% for err in form.username.errors %}
        <span class="err">{{ err }}</span>
      {% endfor %}
    </label>

    {# email — validates on blur (dj-change) so partial input doesn't flash red #}
    <label class="field {% if form.email.errors %}is-invalid{% elif form.email.value %}is-valid{% endif %}">
      Email
      <input
        type="email"
        name="email"
        value="{{ form.email.value|default:'' }}"
        dj-change="validate_field"
        dj-payload-field="email"
        autocomplete="email"
        required
      />
      {% for err in form.email.errors %}
        <span class="err">{{ err }}</span>
      {% endfor %}
    </label>

    {# password — validates on keystroke for live strength feedback #}
    <label class="field {% if form.password.errors %}is-invalid{% elif form.password.value %}is-valid{% endif %}">
      Password
      <input
        type="password"
        name="password"
        dj-input="validate_field"
        dj-debounce="200"
        dj-payload-field="password"
        autocomplete="new-password"
        required
      />
      {% for err in form.password.errors %}
        <span class="err">{{ err }}</span>
      {% endfor %}
    </label>
  </fieldset>

  <button type="submit" {% if not form.is_valid %}disabled{% endif %}
          dj-form-pending="disabled">
    <span dj-form-pending="hide">Sign up</span>
    <span dj-form-pending="show" hidden>Creating account&hellip;</span>
  </button>

  {% if success_message %}
    <p role="status" class="ok">{{ success_message }}</p>
  {% endif %}
</form>

Three deliberate choices:

ChoiceWhy
dj-input="validate_field" on username + passwordLive feedback — user sees "available" / strength meter as they type.
dj-change="validate_field" on emailEmail is "blur to validate" because alice@ shouldn't flash red mid-typing. dj-change fires when the input loses focus or the user hits Enter.
dj-debounce="300" on usernameUsername validation hits the DB (uniqueness check). Without debounce, every keystroke would SELECT 1 FROM auth_user WHERE username=?. 300ms cuts that to ~1 query per "settled typing pause."

The is-invalid / is-valid class hooks let CSS style the field state (red border, green border, neutral border).


Step 4 — A bit of CSS

.field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 1rem;
}
.field input {
  padding: 8px 10px;
  border: 1px solid var(--color-border, #d1d5db);
  border-radius: 4px;
  transition: border-color 0.15s, box-shadow 0.15s;
}
.field.is-invalid input {
  border-color: #dc2626;
  box-shadow: 0 0 0 1px #dc2626;
}
.field.is-valid input {
  border-color: #10b981;
}
.err {
  color: #dc2626;
  font-size: 12px;
}
.ok {
  margin-top: 1rem;
  padding: 12px;
  background: #d1fae5;
  border-left: 3px solid #10b981;
  color: #065f46;
}
button[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

The transition on the input border makes the red/green flip animate smoothly rather than snap. Subtle but it makes the form feel polished.


Step 5 — Try it

Visit /signup/:

  1. Type ab in username → red "Ensure this value has at least 3 characters."
  2. Type abc → field flips green (assuming abc isn't taken).
  3. Type admin → red "That username is taken."
  4. Type alice@ in email — no error yet (didn't blur).
  5. Tab out of email → red if invalid format.
  6. Type pass in password → red password-strength errors from Django's validate_password.
  7. Submit button stays disabled until all three fields are green. Click → form posts; success message renders.

Each per-field validation: ~80 ms round-trip. With debouncing, the DB sees one validation per pause, not one per keystroke. The user gets near-real-time feedback without ever feeling the network.


Why this beats client-side validation libraries

The classic alternative is a JS validation library (Yup, Zod, react-hook-form, etc.) that runs in the browser. It's faster (no round-trip) but it has a fatal flaw: the rules drift from the server. Username uniqueness can't run client-side at all (no DB access). Password strength rules differ between client (lenient) and server (strict). Email validation regexes disagree on edge cases.

You end up with two implementations of "valid": one in JS that the user sees, one in Python that the API enforces. They drift, and the user sees "looks valid" then "rejected on submit" — which is the worst possible UX.

FormMixin collapses both into one Django Form class. The "client validation" is just the server validating fast. The 80 ms round-trip is real but it's tolerable for the consistency guarantee.


When to NOT use FormMixin

The form is…Use
Login form (one shot, no live validation needed)Plain LiveView + @event_handler def login(...)
50-field admin config (validation on submit only)Plain Django form view (not LiveView)
Real-time validation with DB checksFormMixin
Real-time validation, no DB hits, no authCould be either; FormMixin is still simplest

The win of FormMixin is the live-feedback UX. If you don't need that — login forms, settings pages where submit is fine — plain @event_handler with one clean() pass on submit is simpler.


What just happened, end to end

   Browser                                 Server
       │                                       │
       │ user types "abc" in username          │
       │ ── 300ms debounce ─────────────────► validate_field(field="username", value="abc")
       │                                       │   self.form = SignUpForm({...})
       │                                       │   self.form.is_valid()
       │                                       │     → clean_username runs
       │                                       │     → User.objects.filter(...).exists() → False
       │                                       │   self.form.errors["username"] = []
       │ ◄ patch: field flips green ──────────│
       │                                       │
       │ user types "admin"                    │
       │ ── 300ms debounce ─────────────────► validate_field(field="username", value="admin")
       │                                       │   clean_username
       │                                       │     → User.objects.filter("admin").exists() → True
       │                                       │   self.form.errors["username"] = ["That username is taken."]
       │ ◄ patch: red border + error msg ─────│
       │                                       │
       │ user fixes username + email + password│
       │ all three fields green                │
       │ submit button enables                 │
       │                                       │
       │ click Submit                          │
       │ ─────────────────────────────────────► submit() (action)
       │                                       │   self.form.is_valid() — full re-validation
       │                                       │   User.objects.create_user(...)
       │                                       │   self.success_message = "Welcome..."
       │ ◄ patch: success message; form reset ─│

One Form class, two render paths (per-field on type/blur, full on submit). Same validation rules at every step.


Where to go next

  • Async / external-API validators: if a validator needs to hit a slow external service (e.g. "is this domain registered with us"), wrap the validator body in start_async() from the streaming AI tutorial. Show a tiny spinner next to the field while the validator runs.
  • Multi-step forms: combine FormMixin per-step with the multi-step wizard tutorial's cursor pattern. Each step is a separate Form class.
  • CSRF protection: automatic. The dj-submit request includes the CSRF token; the framework validates before invoking your handler.
  • File fields: FormMixin handles forms.FileField via the uploads tutorial pattern — each file upload is a separate WS frame; the validator sees the assembled bytes.
  • Real-time strength meters: beyond Django's password_validation, drop in zxcvbn via a hook for a graphical meter. The hook reads the input value, computes the strength client-side (no validation drift; zxcvbn just produces UX hints), updates the meter as the user types.

The five-line shape — class MyForm(forms.Form): clean_<field>(self): ... plus class MyView(FormMixin, LiveView): form_class = MyForm — is the entire surface. Once you've written one validated form, the next ten are mostly more clean_* methods.

Spotted a typo or want to improve this page? Edit on GitHub →