Forms
A guide to building forms with Fluid Primitives — covering AJAX submission, Extbase integration, client-side validation, and field state management.
Overview
The Form component replaces TYPO3's f:form ViewHelper with an AJAX-first alternative. Instead of a full-page reload, it submits via fetch, handles server-side Extbase validation errors, and updates field state without reloading the page.
What it gives you:
- AJAX submission — no full-page reload
- Automatic Extbase field name prefixing (
tx_myext[MyObject][field]) - 422 error mapping from Extbase validation to individual fields
- Optional client-side pre-validation with Zod
- Form state (
ready,submitting,invalid,success,error) exposed asdata-statefor CSS - Field-level error display, label association, and ARIA wiring via the Field component
- Works with all Field-aware primitives: Select, Checkbox, RadioGroup, NumberInput, and plain HTML inputs
Installation
First you should add the Form and Field primitives to your project:
typo3 ui:add form && typo3 ui:add field
Basic Setup
Template
Use ui:form with action pointing to your Extbase action and objectName matching the argument name in your controller:
<ui:form action="registration" objectName="eventRegistration" object="{eventRegistration}" controlled="{true}" rootId="registration-form">
<ui:field.root name="email" required="{true}">
<ui:field.label>Email</ui:field.label>
<ui:field.control asChild="{true}">
<ui:input type="email" autocomplete="email" />
</ui:field.control>
<ui:field.description>Used for your confirmation email.</ui:field.description>
<ui:field.error />
</ui:field.root>
<ui:button type="submit">Register</ui:button>
</ui:form>
The form renders as a standard <form> with novalidate and the Extbase action URL resolved server-side. The object prop pre-populates field values from an existing model instance.
Controller
<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Controller;
use Vendor\MyExtension\Domain\Model\EventRegistration;
use Jramke\FluidPrimitives\Traits\AjaxValidationTrait;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
final class EventRegistrationController extends ActionController
{
use AjaxValidationTrait;
public function registrationAction(EventRegistration $eventRegistration): ResponseInterface
{
// Process the submission
// $this->eventRegistrationRepository->save($eventRegistration);
return $this->jsonResponse(json_encode(['success' => true]))->withStatus(200);
}
protected function errorAction(): ResponseInterface
{
// Converts Extbase validation errors to a 422 JSON response
$this->throwJsonValidationErrorResponse();
return parent::errorAction();
}
}
The AjaxValidationTrait provides throwJsonValidationErrorResponse(), which intercepts Extbase's normal errorAction redirect and instead returns a 422 JSON response with field-keyed error messages. The Form component reads this response and assigns errors to individual fields.
Pro Tip
If your controller action is not registered as a standalone Plugin you can use throw new PropagateResponseException to return a plain json response.
Entry File (TypeScript)
The form requires a client-side entry file. Use controlled="{true}" on the root and fetch its hydration data by ID:
import { getHydrationData, mount } from 'fluid-primitives';
import { Form } from 'fluid-primitives/form';
mount('my-form', () => {
const data = getHydrationData('form', 'registration-form');
if (!data) return;
const form = new Form({
...data.props,
onSubmit: async ({ formData, api, post }) => {
const response = await post(api.getAction(), formData);
return response.ok;
},
});
form.init();
});
The post() helper automatically adds the Extbase field name prefix (tx_myext[MyObject][field]) before sending, so your form fields can use plain names like email in the template.
The Field Component
ui:field.root wraps any input and wires up labels, errors, descriptions, and ARIA attributes. The name prop is required and must match the property name on your model.
Anatomy
<ui:field.root name="email" required="{true}">
<ui:field.label>Email address</ui:field.label>
<ui:field.control asChild="{true}">
<!-- native input or primitive goes here -->
<ui:input type="email" />
</ui:field.control>
<ui:field.description>We'll send your confirmation here.</ui:field.description>
<ui:field.error />
</ui:field.root>
Parts:
field.label— renders a<label>withforpointing to the controlfield.control— whenasChild="{true}", spreads the field's ARIA attributes onto the child elementfield.description— optional helper text, wired toaria-describedbyfield.error— renders the error message, wired toaria-describedbyand only shown when the field is in an error state
Field Props
ui:field.root accepts these props:
name(string, required) — maps to the model property and form field namerequired(boolean) — marks the field required; propagates to the controldisabled(boolean) — disables the field and all contained controlsreadOnly(boolean) — sets the field and controls to read-onlyinvalid(boolean) — forces the field into an invalid state (e.g. pre-populated server error)defaultValue(mixed) — pre-populates the field value
Inherited Field Props on Primitives
When a Field-aware primitive is placed inside a ui:field.root, the field's state automatically propagates into the primitive. You do not need to repeat disabled, required, etc. on the primitive itself.
<!-- disabled on field.root propagates to the Select automatically -->
<ui:field.root name="country" disabled="{true}">
<ui:select.root collection="{countries}">
<ui:select.label>Country</ui:select.label>
<ui:select.control>
<ui:select.trigger placeholder="Pick a country" />
</ui:select.control>
<ui:select.content>
<f:for each="{countries.items}" as="item">
<ui:select.item item="{item}">
<ui:select.itemText>{item.label}</ui:select.itemText>
</ui:select.item>
</f:for>
</ui:select.content>
</ui:select.root>
<ui:field.error />
</ui:field.root>
Server-Side Validation
Each form should have server-side validation to ensure your controller gets the expected data. Since server-side validation cant be easily bypassed this is our main source of truth. Client-side validation "just" enhances the UX of your forms. You should use both.
Server-side errors are stored by its field and value. So like in the example, when the user submits a VIP registration he gets an error because vip tickets are sold out. When he changes the ticket type to standard the error resolves. However when the user switches back to the vip ticket we automatically show the server side error again.
Extbase Model Validation
Use PHP 8 attributes on your model to declare validation rules. Extbase runs these before your action is called. If validation fails, errorAction is triggered — which the AjaxValidationTrait converts to a 422 JSON response.
<?php
declare(strict_types=1);
namespace Vendor\MyExtension\Domain\Model;
use TYPO3\CMS\Extbase\Annotation\Validate;
class EventRegistration
{
#[Validate(['validator' => 'NotEmpty'])]
#[Validate(['validator' => 'EmailAddress'])]
#[Validate(['validator' => 'StringLength', 'options' => ['maximum' => 255]])]
private string $email = '';
#[Validate(['validator' => 'NotEmpty'])]
#[Validate(['validator' => 'StringLength', 'options' => ['maximum' => 255]])]
private string $name = '';
#[Validate(['validator' => 'Boolean', 'options' => ['is' => true]])]
private bool $privacy = false;
// getters and setters...
}
The 422 JSON response has the shape:
{
"eventRegistration.email": ["This field must be a valid email address."],
"eventRegistration.name": ["This field must not be empty."]
}
The Form component maps each key to the corresponding field by name and displays the error in ui:field.error.
Manual 422 Response
For business-rule validation that doesn't belong in the model, return a 422 directly from your action:
public function registrationAction(EventRegistration $eventRegistration): ResponseInterface
{
if ($eventRegistration->getTicketType() === 'vip') {
$payload = ['eventRegistration.ticketType' => ['VIP tickets are sold out.']];
return $this->jsonResponse(json_encode($payload))->withStatus(422);
}
// continue with save...
}
Generic Error State
When the server returns a non-422 error (500, etc.), the form transitions to data-state="error". Use this to show a fallback message:
<ui:form ... class="group">
<!-- Main form fields, hidden on error or success -->
<div class="group-[[data-state=error]]:hidden group-[[data-state=success]]:hidden">
<!-- fields -->
</div>
<!-- Error state -->
<div class="hidden group-[[data-state=error]]:block">
<p {ui:ref(name: 'error-message')}>Something went wrong. Please try again.</p>
<ui:button type="reset">Back to form</ui:button>
</div>
<!-- Success state -->
<div class="hidden group-[[data-state=success]]:block">
<p>Your registration was submitted successfully. Thank you!</p>
</div>
</ui:form>
Add group to the form's class so Tailwind's group variant selectors work against data-state.
In your entry file, update the error message element from the response body:
onSubmit: async ({ formData, api, post }) => {
const response = await post(api.getAction(), formData);
const json = await response.json();
if (!response.ok) {
const errorMessageEl = hydrator.getElement('error-message');
if (errorMessageEl) {
errorMessageEl.textContent = json.message ?? 'Something went wrong.';
}
}
return response.ok;
},
Client-Side Validation with Zod
To enhance the UX of your forms you should also use (slimmed down) client-side validation in addition to (more complex) server-side validation.
Client-side validation runs on blur and before submission. When a field was touched and has an error or if the form was submitted and resulted in errors, we validate the fields on change so the user can receive immediate feedback when fixing the error.
Install Zod separately:
npm install zod
Pass a Zod schema to the Form constructor. Errors are mapped to fields by key:
import { z } from 'zod';
import { Form } from 'fluid-primitives/form';
const schema = z.object({
name: z.string().min(1, 'Please enter your name'),
email: z.email('Please enter a valid email address'),
ticketCount: z.coerce.number().min(1).max(10),
privacy: z.literal('1', 'You must accept the privacy policy'),
});
const form = new Form({
...data.props,
schema,
onSubmit: async ({ formData, api, post }) => {
const response = await post(api.getAction(), formData);
return response.ok;
},
});
Schema keys must match the field name props in the template. If client-side validation fails, the form stays in the invalid state and focus moves to the first invalid field. The onSubmit callback is not called.
Manual Client-Side Validation
If you prefer not to use Zod, throw a ValidationError from onSubmit:
import { Form, ValidationError } from 'fluid-primitives/form';
const form = new Form({
...data.props,
onSubmit: async ({ formData, api }) => {
const email = formData.get('email') as string;
if (!email || !email.includes('@')) {
throw new ValidationError({
email: { messages: ['Please enter a valid email address.'] },
});
}
const response = await fetch(api.getAction(), {
method: 'POST',
body: formData,
});
return response.ok;
},
});
ValidationError accepts a Record<string, { messages: string[] }> with field names as keys. Throwing it transitions the form to invalid and maps errors to fields exactly like a 422 server response.
Form State
The form element receives a data-state attribute that reflects the current state:
ready— initial state, form is ready for inputsubmitting— submission in progressinvalid— validation failed (client or server)success—onSubmitreturnedtrueerror—onSubmitreturnedfalseor threw a non-validation error
Use data-state in CSS to conditionally show/hide sections or style the submit button:
form[data-submitting] button[type='submit'] {
opacity: 0.5;
pointer-events: none;
}
The render callback on the Form constructor runs every time the form state changes. Use it to update UI elements that are outside the form machine's automatic wiring:
render: form => {
const submitButton = hydrator.getElement('submit-button');
if (submitButton) {
submitButton.setAttribute('aria-disabled', form.api.isSubmitting ? 'true' : 'false');
submitButton.textContent = form.api.isSubmitting ? 'Submitting...' : 'Register';
}
},
Complete Example: Event Registration
A full event registration form with Extbase model validation, a server-side business rule (VIP tickets sold out), and Zod client-side pre-validation.