Form Validation with Datastar ¶
Three-endpoint pattern for clean form validation with real-time feedback and debounced input.
Endpoint 1: Initial Page ¶
from stario import Stario
from stario.datastar import Actions, Attributes
from stario.html import button, div, form, input_, p
from stario.toys import toy_page
app = Stario()
@app.query("/form")
async def form_page(attr: Attributes, act: Actions):
"""Render form page with Datastar validation setup."""
return toy_page(
form(
attr.signals({
"form_data": {"name": "", "email": ""},
"errors": {},
"is_submitting": False,
"success": False,
}),
# Name Input
div(
p("Name"),
input_(
{
"type": "text",
"name": "name",
"placeholder": "Your name",
"class": "input input-bordered w-full",
},
attr.bind("form_data.name"),
attr.on("input", act.post("/validate"), debounce=0.5),
),
p(
attr.show("$errors.name"),
attr.text("$errors.name"),
),
),
# Email Input
div(
p("Email"),
input_(
{
"type": "email",
"name": "email",
"placeholder": "your@email.com",
"class": "input input-bordered w-full",
},
attr.bind("form_data.email"),
attr.on("input", act.post("/validate"), debounce=0.5),
),
p(
attr.show("$errors.email"),
attr.text("$errors.email"),
),
),
# Success Message
p(
attr.show("$success"),
"Form submitted successfully!",
),
# Submit Button
button(
{"type": "button", "class": "btn btn-primary w-full"},
attr.attr({"disabled": "$is_submitting"}),
attr.on("click", act.post("/submit")),
"Submit",
),
),
)
Validation Helpers ¶
def validate_email(email: str) -> str | None:
"""Validate email: must contain '@' character."""
if not email:
return "Email is required"
if "@" not in email:
return "Invalid email format"
return None
def validate_name(name: str) -> str | None:
"""Validate name."""
if not name:
return "Name is required"
if len(name) < 2:
return "Name must be at least 2 characters"
return None
Endpoint 2: Real-time Validation ¶
from stario import Command
from stario.datastar import Signal
@app.command("/validate")
async def validate(
form_data__name: Signal[str],
form_data__email: Signal[str],
):
"""Validate form fields and return errors.
Note: Use double underscores (__) to access nested signal paths.
form_data__name reads the 'form_data.name' signal.
"""
errors = {}
name_error = validate_name(form_data__name)
errors["name"] = name_error if name_error else ""
email_error = validate_email(form_data__email)
errors["email"] = email_error if email_error else ""
# Return updated errors signal
yield {"errors": errors}
Endpoint 3: Form Submission ¶
import asyncio
@app.command("/submit")
async def submit_form(
form_data__name: Signal[str],
form_data__email: Signal[str],
):
"""Submit form after validation."""
errors = {}
has_errors = False
name_error = validate_name(form_data__name)
errors["name"] = name_error if name_error else ""
has_errors = has_errors or name_error
email_error = validate_email(form_data__email)
errors["email"] = email_error if email_error else ""
has_errors = has_errors or email_error
# If validation fails, return errors and stop
if has_errors:
yield {"errors": errors, "is_submitting": False}
return
# Show loading state
yield {"is_submitting": True}
# Simulate saving (replace with actual database save)
await asyncio.sleep(1)
# Success! Clear form and show message
yield {
"success": True,
"is_submitting": False,
"form_data": {"name": "", "email": ""},
"errors": {},
}
How It Works ¶
Page Load ¶
- User visits
/form - Page renders with
toy_page()wrapper and Datastar initialized - Form signals:
form_data,errors,is_submitting,success
Real-time Validation ¶
- User types in input field
attr.on("input", act.post("/validate"), debounce=0.5)waits 500ms after typing stops- Sends current form values to
/validateendpoint - Use
form_data__nameparameter to read nestedform_data.namesignal - Server validates and returns
errorssignal - UI updates to show/hide error messages reactively
Form Submission ¶
- User clicks Submit button
attr.on("click", act.post("/submit"))sends to/submitendpoint- Server validates again (server-side validation always!)
- If valid: shows loading state, simulates save, sets
success: true - Form clears and success message appears
- Button becomes disabled while submitting
Key Features ¶
- ✅ Debounced Input - Validates 500ms after user stops typing
- ✅ Bound Values -
attr.bind()keeps signals in sync with inputs - ✅ Error Display -
attr.show()conditionally shows error messages - ✅ Loading State - Button disabled while submitting
- ✅ Server Validation - Always validates on submit
- ✅ Nested Signals - Use
__for nested paths (e.g.,form_data__name) - ✅ Clean Signals - Single source of truth for form state
Important: Nested Signal Paths ¶
When reading nested signal values like form_data.name, use double underscores in the parameter name:
# ✅ Correct - reads form_data.name signal
@app.command("/validate")
async def validate(form_data__name: Signal[str]):
print(form_data__name) # "John"
# ❌ Wrong - would try to read "form_data.name" as a flat signal name
@app.command("/validate")
async def validate(form_data_name: Signal[str]):
pass
