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 learn | Documented in |
|---|---|
FormMixin + form_class | Forms |
validate_field() for per-field handlers | Forms |
dj-input vs dj-change (keystroke vs blur) | Forms |
dj-debounce to throttle DB-touching validators | This tutorial |
| Async validators that hit the DB without blocking | This 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:
self.form— the form instance, repopulated on each render from the latest field values.self.form.errors— keyed by field name; surfaced in the template.self.form.is_valid— auto-called when the user types in any field (per-field) and on submit (whole form).- The
validate_fieldevent 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…</span>
</button>
{% if success_message %}
<p role="status" class="ok">{{ success_message }}</p>
{% endif %}
</form>
Three deliberate choices:
| Choice | Why |
|---|---|
dj-input="validate_field" on username + password | Live feedback — user sees "available" / strength meter as they type. |
dj-change="validate_field" on email | Email 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 username | Username 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/:
- Type
abin username → red "Ensure this value has at least 3 characters." - Type
abc→ field flips green (assumingabcisn't taken). - Type
admin→ red "That username is taken." - Type
alice@in email — no error yet (didn't blur). - Tab out of email → red if invalid format.
- Type
passin password → red password-strength errors from Django'svalidate_password. - 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 checks | FormMixin |
| Real-time validation, no DB hits, no auth | Could 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
FormMixinper-step with the multi-step wizard tutorial's cursor pattern. Each step is a separateFormclass. - CSRF protection: automatic. The
dj-submitrequest includes the CSRF token; the framework validates before invoking your handler. - File fields:
FormMixinhandlesforms.FileFieldvia 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.