BETA

Recipe · FastAPI · Forms & Validation · State

Multi-Step Wizard

A guided, multi-step form — collect a few fields, validate them, move on, let the user go back and see what they already entered, then do something with the accumulated data on the final step. Built from a single stateful FormComponent that swaps its active schema per step.

FastAPI FormComponent Pydantic HTMX

Before You Build One

Every component-framework request round-trips the component's state to the browser and back — dispatch(state=...) in, result["state"] out. Today that round-trip is unsigned: nothing stops a client from editing the serialized state blob before posting it back.

For a single counter that's harmless. For a wizard that accumulates several steps of validated data, a tampered state blob could let a client skip validation on an earlier step, or submit the final step with fabricated collected data for a step it never actually passed.

Signed state (Epic A1) doesn't exist yet. No CorruptStateError, no sign_state, nothing in core/component.py's StateSerializer as of this writing. CSRF coverage for the FastAPI adapter (A4) is also outstanding — unlike Django, FastAPI has no CSRF handling at all right now. This recipe is written against the current, unsigned model so you can build a wizard today; swap in the signed-state API once A1 lands (no change to the component's own logic should be needed), and add app-level CSRF protection for the wizard's POST route until A4 ships.

Until then: don't put anything in a wizard's accumulated state that you wouldn't be comfortable with a malicious client tampering with. If the final step's handler is about to write to your database, re-validate the data you actually need server-side rather than trusting collected blindly — re-check foreign keys exist, re-check ownership.

Why Not CompositeComponent?

If you've read the framework's docs or README, you may expect a CompositeComponent host with one child FormComponent per step, wired via slots. That primitive doesn't exist in the codebase today — only Component.fill_slot() / render_slots() and the compose() helper in core/composition.py, which compose components for rendering (a parent template with pre-rendered child HTML dropped into named slots).

That's a fundamentally different problem than a wizard's needs. Each POST to /components/{name} dispatches exactly one registered component — hydrate → handle event → render → dehydrate — for that one component only. There's no framework machinery that would hydrate a parent and an independently addressable per-step child component in the same request and keep both in sync across steps.

The pattern that actually works: one component owns the whole wizard. It tracks which step is active and the data collected so far, and swaps which schema it validates against based on the active step. Modeling this as a parent + slotted children would mean inventing your own protocol for shuttling child state through the parent's state anyway — at which point you've just built the pattern below with extra ceremony.

The Component

Three-step scenario: contact info → target role → review/generate. The first two steps each validate against their own Pydantic schema; the review step has no fields of its own.

examples/fastapi_wizard_example.py Python
from typing import ClassVar
from pydantic import BaseModel, EmailStr, Field
from component_framework.core import FormComponent, registry


class ContactStepSchema(BaseModel):
    name: str = Field(min_length=2, max_length=100)
    email: EmailStr


class TargetRoleStepSchema(BaseModel):
    job_title: str = Field(min_length=2, max_length=100)
    company: str = Field(min_length=2, max_length=100)


# The final "review" step has no fields to validate, so no schema.
STEPS: list[dict] = [
    {"key": "contact", "title": "Contact Info", "schema": ContactStepSchema},
    {"key": "target_role", "title": "Target Role", "schema": TargetRoleStepSchema},
    {"key": "review", "title": "Review & Generate", "schema": None},
]


@registry.register("resume_wizard")
class ResumeWizard(FormComponent):
    steps: ClassVar[list[dict]] = STEPS
    template_name = "Wizard"

    def mount(self):
        super().mount()
        self.state.setdefault("step_index", 0)
        self.state.setdefault("collected", {})
        self._load_current_step_form_data()

    def _current_step(self) -> dict:
        return self.steps[self.state["step_index"]]

    def _load_current_step_form_data(self):
        """Pre-fill from previously-entered data when navigating back."""
        step = self._current_step()
        self.state["form_data"] = self.state["collected"].get(step["key"], {})
        self.field_errors = {}

    @property
    def schema(self):
        """FormComponent.validate() reads this — point it at the active step."""
        return self._current_step().get("schema")

    def on_advance(self, form_data: dict):
        step = self._current_step()
        self.state["form_data"] = form_data

        if step.get("schema") and not self.validate(form_data):
            return  # field_errors populated by validate(); stay on this step

        if step.get("schema"):
            self.state["collected"][step["key"]] = self.validated_data

        if self.state["step_index"] < len(self.steps) - 1:
            self.state["step_index"] += 1
            self._load_current_step_form_data()

    def on_back(self):
        if self.state["step_index"] > 0:
            self.state["step_index"] -= 1
            self._load_current_step_form_data()

    def on_submit(self):
        """Final step — the app persists self.state["collected"], not the framework."""
        self.state["completed"] = True

Three things make this work:

The Template

The template switches which fields it renders based on step_key, and posts each field's live value explicitly via hx-vals='js:{...}' — rather than relying on hx-include, which doesn't nest into the component endpoint's payload.form_data shape.

templates/components/Wizard.jinja (Next button, step 1) HTML
<button
  type="button"
  hx-post="/components/resume_wizard"
  hx-vals='js:{"event": "advance", "payload": {"form_data": {"name": document.getElementById("wiz-name").value, "email": document.getElementById("wiz-email").value}}, "state": {{ state | tojson }}, "params": {"component_id": "{{ component_id }}"}}'
  hx-target="#{{ component_id }}"
  hx-swap="outerHTML"
>Next</button>

"Back" posts a "back" event with an empty payload; the final step's "Generate" button posts "submit".

State on Navigation

Running It

terminal Shell
uv run python examples/fastapi_wizard_example.py
  1. Open http://localhost:8000 — fill in contact info and target role.
  2. Try an invalid email — see per-field validation without losing your place.
  3. Click Back — confirm your earlier answers are still there.
  4. Click Generate on the review step to complete the wizard.

Summary

One component, one template, no invented composition protocol.

Concern Handled by Notes
Active step state["step_index"] Advances/retreats via on_advance / on_back
Per-step validation schema property Points FormComponent.validate() at the active step's model
Accumulated data state["collected"] Keyed by step, untouched by Back navigation
Final persistence on_submit (app-level) Framework hands you the data; saving it is your code
Other adapters. Litestar's dispatch model mirrors FastAPI's closely enough that porting this recipe should be close to mechanical (tracked separately). Django and Flask need adapter-specific design — Django already has CSRF and session infrastructure worth showing instead of bypassing, and Flask's adapter is newer and less battle-tested — so those are tracked as their own follow-ups rather than bundled here.