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

  1. User visits /form
  2. Page renders with toy_page() wrapper and Datastar initialized
  3. Form signals: form_data, errors, is_submitting, success

Real-time Validation

  1. User types in input field
  2. attr.on("input", act.post("/validate"), debounce=0.5) waits 500ms after typing stops
  3. Sends current form values to /validate endpoint
  4. Use form_data__name parameter to read nested form_data.name signal
  5. Server validates and returns errors signal
  6. UI updates to show/hide error messages reactively

Form Submission

  1. User clicks Submit button
  2. attr.on("click", act.post("/submit")) sends to /submit endpoint
  3. Server validates again (server-side validation always!)
  4. If valid: shows loading state, simulates save, sets success: true
  5. Form clears and success message appears
  6. 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