---
title: "Tutorial: Real-time form validation with FormMixin"
slug: tutorial-form-validation
section: guides
order: 76
level: intermediate
description: "Build a sign-up form that validates each field as the user types — username availability, password strength, email format — using Django Forms inside FormMixin. Errors appear inline within ~80 ms; no full-form re-renders, no JavaScript validation library."
---

# 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](/guides/forms/) |
| `validate_field()` for per-field handlers | [Forms](/guides/forms/) |
| `dj-input` vs `dj-change` (keystroke vs blur) | [Forms](/guides/forms/) |
| `dj-debounce` to throttle DB-touching validators | This tutorial |
| Async validators that hit the DB without blocking | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), familiarity
> with Django Forms ([docs](https://docs.djangoproject.com/en/stable/topics/forms/))
> and the [search-as-you-type tutorial](/guides/tutorial-search-as-you-type/)
> (sets up the debouncing vocabulary).

---

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

```python
# 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

```python
# 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

```html
<!-- 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:

| 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

```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 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](/guides/tutorial-streaming-ai/).
  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](/guides/tutorial-multi-step-wizard/)'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](/guides/tutorial-file-uploads-progress/)
  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](https://github.com/dropbox/zxcvbn)
  via a [hook](/guides/tutorial-chart-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.
