Live Validation (HTMX)¶
Live validation checks a field on the server while the user types or when they leave the field, and shows the result next to the form without a page reload. It uses HTMX: the field posts its value to a small endpoint and HTMX swaps the endpoint's answer into the page.
It's a convenience on top of normal validation, not a replacement: the submit
route still calls validate(), because JavaScript can be turned off.
A complete example¶
Three files: the model and its live validator, a page template, and the routes.
from pydantic import EmailStr
from pydantic_schemaforms import Field, FormModel, LiveValidator
def live(field: str) -> dict[str, str]:
"""HTMX attributes that send the field to /validate/<field> as the user types."""
return {
'hx-post': f'/validate/{field}',
'hx-trigger': 'blur, input delay:600ms',
'hx-target': f'#{field}-feedback',
'hx-swap': 'innerHTML',
}
class ContactForm(FormModel):
name: str = Field(title='Full name', min_length=2, ui_options=live('name'))
email: EmailStr = Field(title='Email', ui_options=live('email'))
live_validator = LiveValidator.for_model(ContactForm, debounce_ms=600)
from models import live_validator
from pydantic_schemaforms import SafeHTML, html
VALIDATOR_SCRIPT = SafeHTML(live_validator.render_htmx_script())
def page(form_html: str) -> str:
form = SafeHTML(form_html) # trusted HTML from the library
return html(t"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Contact</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<script src="https://unpkg.com/htmx.org@2.0.7"></script>
{VALIDATOR_SCRIPT}
</head>
<body>
<main class="container my-5">
{form}
<div id="name-feedback" aria-live="polite"></div>
<div id="email-feedback" aria-live="polite"></div>
</main>
</body>
</html>""")
def feedback(errors: list[str]) -> str:
if not errors:
return html(t'<span class="text-success">Looks good</span>')
message = '; '.join(errors) # may echo what the user typed: html() escapes it
return html(t'<span class="text-danger">{message}</span>')
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import ContactForm, live_validator
from page import feedback, page
from pydantic_schemaforms import html, render_form_html_async, validation_response_headers
app = FastAPI()
@app.get('/contact', response_class=HTMLResponse)
async def show_contact():
return page(await render_form_html_async(ContactForm, submit_url='/contact'))
@app.post('/contact', response_class=HTMLResponse)
async def submit_contact(request: Request):
result = ContactForm.validate(await request.form(), submit_url='/contact')
if not result.is_valid:
return page(await result.render_with_errors_async())
name = result.data['name']
return html(t'<p>Thanks, {name}!</p>')
@app.post('/validate/{field_name}', response_class=HTMLResponse)
async def validate_field(field_name: str, request: Request):
value = str((await request.form()).get(field_name, ''))
result = live_validator.validate_field(field_name, value)
return HTMLResponse(
feedback(result.errors),
headers=validation_response_headers(field_name, result.is_valid),
)
from flask import Flask, request
from models import ContactForm, live_validator
from page import feedback, page
from pydantic_schemaforms import html, render_form_html, validation_response_headers
app = Flask(__name__)
@app.get('/contact')
def show_contact():
return page(render_form_html(ContactForm, submit_url='/contact'))
@app.post('/contact')
def submit_contact():
result = ContactForm.validate(request.form, submit_url='/contact')
if not result.is_valid:
return page(result.render_with_errors())
name = result.data['name']
return html(t'<p>Thanks, {name}!</p>')
@app.post('/validate/<field_name>')
def validate_field(field_name: str):
value = request.form.get(field_name, '')
result = live_validator.validate_field(field_name, value)
return feedback(result.errors), validation_response_headers(field_name, result.is_valid)
Typing "A" in the name and pausing shows "String should have at least 2 characters"; finishing the name shows "Looks good".
How it works¶
LiveValidator.for_model(ContactForm)derives a check for every field from the model itself: its constraints, its type (EmailStr) and its@field_validators. There's nothing to keep in sync.ui_options=live("name")adds HTMX attributes to the input: post to/validate/nameon blur and 600 ms after the user stops typing, and put the answer into#name-feedback. Anyui_optionskey that isn't a known option is written onto the input as an attribute.- One endpoint serves every field.
validate_field(name, value)runs that field's check and returns a result withis_validanderrors. An unknown field name is reported as valid, so a stray request never breaks the page. validation_response_headers()tells the script fromrender_htmx_script()the outcome, so it marks the inputis-validoris-invalidand copies the message into the input's tooltip.
The feedback is built with html(t"...") because a message can include what
the user typed: email-validator's "The domain name example.con does not
exist", for example. The feedback elements use aria-live="polite" so screen
readers announce the result.
Checks that aren't in the model¶
FieldValidator builds a check from rules, with your own messages. Register
it after for_model() to replace that one field's check:
from pydantic_schemaforms import CustomRule, FieldValidator
username = FieldValidator("username")
username.min_length(3, message="At least 3 characters")
username.add_rule(CustomRule(
lambda value: not value.lower().startswith("admin"),
message="Usernames can't start with 'admin'",
))
live_validator.register_field_validator(username)
The available rules are in Validation Rules.
Email addresses that can receive mail¶
DeliverableEmailStr is a drop-in replacement for EmailStr that also looks
up the domain's mail servers (MX records), so ada@example.con is rejected
with a message saying why. It works in validate() and in live validation
with nothing else to set up, and needs the email extra
(pydantic-schemaforms[email]).
It's a real DNS lookup, so it always runs on the server and takes a moment:
keep a debounce of at least 600 ms for it, or check it on blur only with
live(...) returning "hx-trigger": "blur" for that field.
Triggers and debounce¶
for_model(..., debounce_ms=600) checks on blur and as the user types. For
other combinations, pass an HTMXValidationConfig:
from pydantic_schemaforms import HTMXValidationConfig, LiveValidator
config = HTMXValidationConfig(
validate_on_blur=True,
validate_on_input=False, # blur only
validate_on_change=False,
debounce_ms=400,
)
live_validator = LiveValidator.for_model(ContactForm, config=config)
The trigger written on the input (hx-trigger) is set by your ui_options,
so keep the two in step.
Several forms, one endpoint¶
LiveValidator.for_model(ContactForm, SignupForm) covers several forms with
one validator and one /validate/{field_name} route. Field names must be
unique across those forms; for a repeated name, the last one registered wins.
Styling the result¶
Bootstrap styles is-valid and is-invalid on inputs out of the box. For
Material or plain HTML, add your own rule:
input.is-invalid { border-color: #dc3545; }
input.is-valid { border-color: #198754; }
Rendering a single field by hand
live_validator.render_field_with_live_validation("email", field_type="email", label="Email")
returns one labelled input with its HTMX attributes and its own feedback
element, for pages where you build the markup yourself instead of
rendering a whole FormModel.
The example app's /live-validation and /email-dns-validation pages run
these patterns live.