djust docs
Browse documentation
intermediate

Multi-Step Form Wizards

Build guided multi-step form flows with WizardMixin — step navigation, per-step validation, and data collection over WebSocket

On this page

WizardMixin manages step navigation, per-step validation, and data collection for guided multi-step form flows. Everything runs over WebSocket with no page reloads.

Quick Start

1. Define Your Forms

from django import forms

class PersonalInfoForm(forms.Form):
    first_name = forms.CharField(max_length=100)
    last_name = forms.CharField(max_length=100)
    email = forms.EmailField()

class AddressForm(forms.Form):
    street = forms.CharField(max_length=200)
    city = forms.CharField(max_length=100)
    state = forms.CharField(max_length=2)
    zip_code = forms.CharField(max_length=10)

2. Create the LiveView

from djust import WizardMixin, LiveView

class SignupWizard(WizardMixin, LiveView):
    template_name = "signup/wizard.html"

    wizard_steps = [
        {"name": "personal", "title": "Personal Info", "form_class": PersonalInfoForm},
        {"name": "address",  "title": "Address",       "form_class": AddressForm},
        {"name": "review",   "title": "Review & Submit"},  # no form — informational
    ]

    def on_wizard_complete(self, step_data):
        # step_data = {
        #   "personal": {"first_name": "Rosa", "last_name": "Mendez", "email": "..."},
        #   "address":  {"street": "...", "city": "...", "state": "...", "zip_code": "..."},
        # }
        User.objects.create(
            first_name=step_data["personal"]["first_name"],
            last_name=step_data["personal"]["last_name"],
            email=step_data["personal"]["email"],
        )

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # Pair each rendered field with its errors. A template can't look up
        # step_errors by a loop variable (step_errors.name reads the literal
        # key "name"), so build the list here.
        context["fields"] = [
            {"name": name, "html": html, "errors": context["step_errors"].get(name, [])}
            for name, html in context["field_html"].items()
        ]
        return context

Place WizardMixin before LiveView in the class definition so its methods take priority.

3. Build the Template

{% load live_tags %}

<div dj-root>

    {# Step indicator #}
    <div class="steps">
        {% for step in steps %}
            <span class="{% if step.is_current %}active{% endif %}
                         {% if step.is_completed %}completed{% endif %}">
                {{ step.title }}
            </span>
        {% endfor %}
    </div>

    {# Progress bar #}
    <div class="progress-bar" style="width: {{ progress_percent }}%"></div>

    {# Current step form fields #}
    {% for field in fields %}
        <div class="field">
            {{ field.html|safe }}
            {% for error in field.errors %}
                <span class="error">{{ error }}</span>
            {% endfor %}
        </div>
    {% endfor %}

    {# Navigation buttons #}
    <div class="wizard-nav">
        {% if can_go_back %}
            <button dj-click="prev_step">Back</button>
        {% endif %}

        {% if is_last_step %}
            <button dj-click="submit_wizard">Submit</button>
        {% else %}
            <button dj-click="next_step">Continue</button>
        {% endif %}
    </div>
</div>

That's it. The wizard handles validation, navigation, and data collection automatically.

How It Works

  1. mount() initializes step state (index, data, errors, completed steps)
  2. User fills in fields -- dj-change="validate_field" stores each value as it changes (no validation yet). A field with a callable initial (UUIDField(initial=uuid.uuid4), initial=timezone.now) is drawn once, when its step first renders, and stored in the step's data as the text the widget shows, so the value on the page is the value the step submits
  3. User clicks "Continue" -- next_step validates the current step, advances if valid
  4. User clicks "Back" -- prev_step goes back without validation (data is preserved)
  5. On the last step, "Submit" calls submit_wizard which re-validates ALL steps, then calls on_wizard_complete()

Event Handlers

All handlers are available via dj-click or dj-submit in templates:

HandlerDescription
next_stepValidate current step, advance if valid
prev_stepGo back one step (no validation, data preserved)
go_to_stepJump to a completed step (data-step_index="N")
update_step_fieldStore a single field value (data-field="name")
validate_fieldStore a field's value as the user edits it (used by as_live_field()); validation runs on next_step / submit_wizard
submit_wizardValidate all steps, call on_wizard_complete() if valid

Jumping to a Step

Users can click on completed steps in the step indicator:

{% for step in steps %}
    {% if step.is_completed %}
        <button dj-click="go_to_step" data-step_index="{{ step.index }}">
            {{ step.title }}
        </button>
    {% endif %}
{% endfor %}

go_to_step only allows jumping to completed steps or the current step -- users cannot skip ahead past unvalidated steps.

Template Context

Every render provides these variables:

Step State

VariableTypeDescription
current_stepdict{name, title, index} of current step
total_stepsintTotal number of steps
progress_percentintfloor(index / total * 100)
stepslistAll steps with is_current / is_completed flags
VariableTypeDescription
can_go_backboolTrue if not on first step
can_go_forwardboolTrue if current step is completed
is_first_stepboolTrue if on step 0
is_last_stepboolTrue if on the final step

Form Data

VariableTypeDescription
form_datadict{field_name: value} for current step
form_requireddict{field_name: bool} required flags
form_choicesdict{field_name: [{value, label}]} for choice fields
field_htmldict{field_name: SafeString} pre-rendered widget HTML
step_errorsdict{field_name: [errors]} for current step
step_datadictAll data across all steps

Choice fields are also exposed as flat top-level variables (e.g. status_choices, borough_choices) for convenience.

Pre-Rendered Field HTML

The Rust template engine cannot call Python methods with arguments, so form fields must be pre-rendered in Python. WizardMixin does this automatically via as_live_field():

{# These are pre-rendered HTML strings with dj-change bindings #}
{{ field_html.first_name|safe }}
{{ field_html.email|safe }}

Each field carries dj-change="validate_field" (or dj-input for text widgets when wizard_input_event = "dj-input") and its name="<field>", which the handler receives as field. validate_field only stores the value; validation runs on next_step / submit_wizard.

You can also render fields manually in get_context_data():

def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    # Custom event handler for a specific field
    context["custom_field"] = self.as_live_field("email", event_name="check_email")
    return context

Informational Steps (No Form)

Steps without a form_class are informational -- they always pass validation and are useful for review or confirmation pages:

wizard_steps = [
    {"name": "info",    "title": "Your Info",    "form_class": InfoForm},
    {"name": "review",  "title": "Review"},       # no form_class
    {"name": "confirm", "title": "Confirmation"}, # no form_class
]

On review steps, display the collected data:

{% if current_step.name == "review" %}
    <h3>Please review your information:</h3>
    <p>Name: {{ step_data.info.first_name }} {{ step_data.info.last_name }}</p>
    <p>Email: {{ step_data.info.email }}</p>
{% endif %}

Persisting Data

Override on_wizard_complete() to save the collected data:

def on_wizard_complete(self, step_data):
    from django.db import transaction

    with transaction.atomic():
        user = User.objects.create(
            first_name=step_data["personal"]["first_name"],
            last_name=step_data["personal"]["last_name"],
            email=step_data["personal"]["email"],
        )
        Address.objects.create(
            user=user,
            street=step_data["address"]["street"],
            city=step_data["address"]["city"],
        )

Important: step_data contains raw string values, not Django cleaned_data. Parse dates, numbers, etc. yourself:

def on_wizard_complete(self, step_data):
    from datetime import date

    birth_date = date.fromisoformat(step_data["personal"]["date_of_birth"])

This is intentional. Django's cleaned_data contains Python objects (datetime.date, Decimal) that are not JSON-serializable. Since djust serializes all public state to JSON between WebSocket events, storing cleaned_data would corrupt the data on the next event.

Security

submit_wizard re-validates ALL previous steps before calling on_wizard_complete(). This guards against tampered WebSocket event replays -- template-level button visibility ({% if is_last_step %}) is not a security boundary because dj-click events can be sent directly over the WebSocket.

If re-validation fails for any step, the wizard navigates back to the failing step and shows the errors.

Step Definition Reference

Each step in wizard_steps is a dict with these keys:

KeyRequiredTypeDescription
nameyesstrUnique step identifier
titlenostrHuman-readable step title
form_classnoForm classDjango Form for this step

Steps without form_class are informational and always pass validation.

Combining with Other Mixins

WizardMixin composes with other djust mixins:

from djust import WizardMixin, LiveView
from djust.auth import LoginRequiredMixin

class SecureWizard(LoginRequiredMixin, WizardMixin, LiveView):
    login_required = True
    wizard_steps = [...]

Place WizardMixin before LiveView and after auth mixins in the MRO.