A six-step application wizard —
Personal → Address → Employment → Preferences
→ Documents → Confirm — built end-to-end
with Symfony's FormFlow component, shadcn/ui, and
Reactolith on the client. The source lives at
examples/symfony-multistep-form/
in the Reactolith repo.
Every visual decision is centralised in a single Twig form theme
(templates/form/shadcn_form_theme.html.twig); every
interactive concern is a kebab-cased custom element that Reactolith
hydrates into a React component on mount.
TextType, EmailType,
NumberType, IntegerType,
ChoiceType (single, multi, expanded),
CheckboxType, DateType,
DateTimeType, TimeType,
ColorType, RangeType,
UrlType, PasswordType,
FileType, CollectionType,
HiddenType, UuidType,
UlidType.
PreviousFlowType — click one and the flow
jumps straight to that step.
UploadedFile objects into a
temp directory before FormFlow's session storage tries to
serialise them, then surfaces them again as "already uploaded"
chips on the way back.
data-prototype HTML gets parsed into a
DOM node and rendered through
<ReactolithComponent> so newly-added rows
hydrate exactly like server-rendered ones.
localStorage with an inline boot
script to avoid the light-mode flash.
The Symfony form theme emits kebab-cased custom elements instead
of native ones — <ui-input>,
<ui-select>, <ui-checkbox>,
etc. Reactolith resolves them at hydration time to React
components in assets/components/ui/:
The matching React component reads errors via
useFormErrors so the same field can render a
validation message without the theme having to know about
state:
Reactolith wraps every DOM child in a ReactolithComponent
before passing it to your component — which means a Select
can't inspect its <option> children the way it
would with raw JSX. The fix is to pass options as JSON instead:
The React component receives options as a plain
array prop, renders a real popover with keyboard navigation and a
search box, and writes a single <input type="hidden">
so the form data still matches the field's name.
Symfony's SessionDataStorage serialises the model
between steps. UploadedFile and even plain
SplFileInfo block serialisation — so this
example hooks the SUBMIT event on each file field
to move the upload into a temp directory and then teaches the
owning model how to (de)serialise file paths:
When a step is shown again after going back, the FileInput renders
every previously-uploaded file as an "already uploaded" chip with
its own hidden input named <field>_keep[].
Clicking the X drops the hidden input from the DOM; the parent
form's PRE_SUBMIT reads the remaining keep list and
reconciles it against the model's previous data.
Symfony's data-prototype attribute on a
CollectionType is HTML — when you append it client-side,
its custom-element children don't get hydrated by default. The
example uses Reactolith's exported
ReactolithComponent to render the parsed prototype
DOM through the same loader pipeline as everything else:
FormFlow's PreviousFlowType already accepts a step
name as its view data — pass it to
$flow->movePrevious(...) and you skip straight
to that step. The progress component just renders each completed
step as a real submit button named after the previous button's
HTML name:
The Twig template passes the previous button's full HTML name
(e.g. application_flow[navigator][previous]) to
<ui-progress> via a
previous-name attribute. Reactolith's submit
handler picks up the clicked event.submitter and
adds name=value to the FormData payload, so
FormFlow sees the exact button it expects.
Visit http://127.0.0.1:8000/apply and walk through
the wizard. Try filling out three steps, going back two via the
progress indicator, uploading a PDF, removing it, replacing it,
and toggling dark mode in the top-right corner.
The patterns surfaced here are all documented in their own right:
<Form> component,
useFormErrors and the round-trip morph.
<link rel="modulepreload"> tags
for the components a page actually renders.