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.
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 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.
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:
- Schema becomes a property, not a fixed
ClassVar.FormComponent.validate()readsself.schema— overriding it as a property lets each step supply its own Pydantic model without per-step component subclasses. on_advancevalidates, then decides whether to move. A failed validation leavesstep_indexuntouched and populatesfield_errorsexactly like a single-pageFormComponentwould.- The last step reuses
"submit", not"advance". Since the review step hasschema: None,FormComponent.handle_event's existing"submit"handling (validate → callon_submit) works unchanged.
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.
<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
- In-flight wizard data lives entirely in the client-held state blob (unsigned today, signed once A1 lands) — not on the server, not in a database. If the user closes the tab mid-wizard, everything they entered is gone unless your app persists it somewhere.
- Going back doesn't discard anything.
on_backonly movesstep_index;collectedis untouched, so_load_current_step_form_datare-populates the earlier step's fields from what's already stored. - The framework never writes anything server-side.
on_submitis where your app takesself.state["collected"]and does something durable with it — component-framework's job ends at handing you validated, accumulated data.
Running It
uv run python examples/fastapi_wizard_example.py
- Open
http://localhost:8000— fill in contact info and target role. - Try an invalid email — see per-field validation without losing your place.
- Click Back — confirm your earlier answers are still there.
- 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 |