Recipe: Streamlit / Gradio wizard

A multi-step form whose steps, back/forward navigation and validation live in a statechart. Only its JSON snapshot is kept in st.session_state or gr.State. The diagram is embedded in the app.

Multi-step forms in Streamlit and Gradio usually end up as a step integer and a thicket of ifs: which step comes after which, when Next is allowed, and what Back keeps. Put those rules in a chart instead. Back and Next are events, validation is a guard on NEXT, and the UI only renders whichever step is active.

Both frameworks rerun your code on every click, so the wizard keeps only the snapshot, a JSON string. It lives in st.session_state or in a gr.State dict. Each click is restore → send → snapshot.

Files: examples/recipes/form_wizard/. wizard.py holds the framework-free core. streamlit_app.py and gradio_app.py are the front ends.

xsm simulate examples/recipes/form_wizard/machine.json --events NEXT,BACK,NEXT,NEXT,SUBMIT
# -> signup.done    (every guard stubbed True; add --guards-false accountValid to see NEXT refused)
xsm diagram examples/recipes/form_wizard/machine.json      # the Mermaid the apps embed

The core

from xstate_statemachine import MachineLogic, SyncInterpreter, create_machine

chart = {
    "id": "signup",
    "initial": "account",
    "context": {
        "email": "",
        "plan": "",
    },
    "states": {
        "account": {
            "on": {
                "NEXT": {
                    "target": "plan",
                    "guard": "accountValid",
                    "actions": "save",
                },
            },
        },
        "plan": {
            "on": {
                "BACK": "account",
                "NEXT": {
                    "target": "done",
                    "guard": "planChosen",
                    "actions": "save",
                },
            },
        },
        "done": {
            "type": "final",
        },
    },
}


def save(i, ctx, e, a):
    ctx.update({k: v for k, v in e.payload.items() if k in ctx})


machine = create_machine(
    chart,
    logic=MachineLogic(
        actions={
            "save": save,
        },
        guards={
            "accountValid": lambda ctx, e: "@" in e.payload.get("email", ""),
            "planChosen": lambda ctx, e: (
                e.payload.get("plan") in ("free", "pro")
            ),
        },
    ),
)


def click(session: dict, event: str, **fields) -> bool:
    """One UI interaction: restore -> send -> snapshot. False = input refused."""
    blob = session.get("wizard")
    i = (
        SyncInterpreter.from_snapshot(blob, machine)
        if blob
        else SyncInterpreter(machine)
    ).start()
    changed = i.send(event, wait=True, **fields).changed
    session["wizard"] = i.get_snapshot()  # a JSON string: session-state safe
    i.stop()
    return changed


session = {}  # st.session_state / the dict in gr.State
assert (
    click(session, "NEXT", email="nope") is False
)  # the guard refuses; show an error
assert click(session, "NEXT", email="ann@example.com")
assert click(session, "BACK") and click(
    session, "NEXT", email="ann@example.com"
)
assert click(session, "NEXT", plan="pro")
assert '"done"' in session["wizard"]

wizard.Wizard(session) in the example wraps exactly this, and adds .step, .data and diagram().

Streamlit

import streamlit as st
from wizard import PLANS, Wizard, diagram

wiz = Wizard(st.session_state)
st.header(f"Sign up -- {wiz.step}")
if wiz.step == "account":
    email = st.text_input("Email", value=wiz.data["email"])
    if st.button("Next") and not wiz.send("NEXT", email=email):
        st.error("Enter a valid email address.")
elif wiz.step == "plan":
    plan = st.radio("Plan", PLANS)
    if st.button("Back"):
        wiz.send("BACK")
    elif st.button("Next"):
        wiz.send("NEXT", plan=plan)
# ... confirm / done ...
with st.expander("How this form works"):
    fence = "`" * 3                     # a literal fence would end this block
    st.markdown(f"{fence}mermaid\n{diagram()}\n{fence}")    # the chart, embedded

Gradio

import gradio as gr
from wizard import PLANS, Wizard, diagram


def on_next(state, email, plan, accepted):
    state = dict(state or {})
    wiz = Wizard(state)
    fields = {
        "account": {
            "email": email,
        },
        "plan": {
            "plan": plan,
        },
        "confirm": {
            "accepted": accepted,
        },
    }.get(wiz.step, {})
    wiz.send("SUBMIT" if wiz.step == "confirm" else "NEXT", **fields)
    return state, f"## Sign up -- {wiz.step}"


with gr.Blocks() as demo:
    session = gr.State({})
    header = gr.Markdown("## Sign up -- account")
    email, plan, terms = (
        gr.Textbox(label="Email"),
        gr.Radio(list(PLANS)),
        gr.Checkbox(),
    )
    gr.Button("Next").click(
        on_next, [session, email, plan, terms], [session, header]
    )
    fence = "`" * 3
    gr.Markdown(f"{fence}mermaid\n{diagram()}\n{fence}")

Neither library is a dependency. tests/recipes/test_form_wizard.py imports both apps against stub streamlit and gradio modules. It clicks through the whole wizard, refused input included, and checks that the Mermaid diagram is rendered.

Troubleshooting

You see Why Fix
Next does nothing The guard refused the input: send() returns False (xsm simulate … --events NEXT --guards-false accountValid prints NEXT denied by guard). Show an error when send() returns False.
ModuleNotFoundError: No module named 'wizard' The apps import wizard.py from their own folder. Copy the whole examples/recipes/form_wizard/ folder and run from inside it.
The wizard restarts on every click The snapshot was stored somewhere that does not survive a rerun (a local variable instead of st.session_state / gr.State). Pass the session mapping to Wizard(...).

Why a chart here

  • Back keeps data. BACK is a plain transition, so the context, and with it everything typed so far, is untouched.
  • Validation is declarative. NEXT has a guard. A refused NEXT returns changed=False and your UI shows the error. No step counter can drift out of range.
  • Adding a step is a chart edit. Draw it in Stately, re-export machine.json, and add one elif for its widgets.
  • The session is small and serializable. It is a JSON string, not live objects, so it survives Streamlit reruns and Gradio queueing.

Related: Flask session-keyed wizards, Diagrams, all recipes.