Skip to main content

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:

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.root 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.root>

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.

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 ({ api, post }) => {
            const response = await post(api.getAction());
            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 or nested dot paths like person.name in the template. It always submits the current form as FormData, and it also handles 422 JSON validation responses for you by mapping them back to fields and transitioning the form to invalid.

The Form API exposes a FormValues object via api.getValues() and inside validation / onSubmit callbacks. FormValues keeps leaf values uncoerced as string | File, supports get(path), getAll(path), has(path), pick(path), and toObject(), and uses your field names as canonical dot paths. That means ticketCount still reads as '2', not 2, until your validator or application code converts it.

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 Props

ui:field.root accepts these props:

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...
}

Submit Results And Status States

onSubmit can return three outcomes:

For form-level messaging, use api.setErrorText() and api.setSuccessText(). The form.content, form.indicator, form.errorText, and form.successText primitives remove the need for group-[[data-state=...]] selectors:

<ui:form.root ...>
    <ui:form.content>
        <!-- fields -->
    </ui:form.content>

    <ui:form.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Error')}">
        <ui:form.errorText>Something went wrong. Please try again.</ui:form.errorText>
        <ui:button type="reset">Back to form</ui:button>
    </ui:form.indicator>

    <ui:form.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\FormState::Success')}">
        <ui:form.successText>Your registration was submitted successfully. Thank you!</ui:form.successText>
    </ui:form.indicator>
</ui:form.root>

Set the status text in your entry file and return the matching submit result:

onSubmit: async ({ api, post }) => {
    const response = await post(api.getAction());
    const json = await response.json();

    if (!response.ok) {
        api.setErrorText(json.message ?? 'Something went wrong.');
        return false;
    }

    api.setSuccessText(json.message ?? 'Your registration was submitted successfully.');
    return true;
},

When you use post(), 422 JSON validation responses do not come back as a normal Response. They are intercepted by the Form primitive, mapped to field errors automatically, and transition the form to invalid.

Client-Side Validation

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 for dirty fields and before submission. Once a field already has an error, we validate it on change too so the user gets immediate feedback while fixing it.

Each field also tracks local interaction metadata. field.meta.isTouched becomes true after the first change or blur, field.meta.isDirty stays true once the value was changed, field.meta.isPristine is the inverse of isDirty, field.meta.isBlurred becomes true after the first blur, and field.meta.isDefaultValue reflects whether the current value matches the initial value. The same state is mirrored to field.root as data-touched, data-dirty, data-pristine, data-blurred, and data-default-value attributes for styling.

Install your validator separately. For Zod:

npm install zod

You do not need an extra Standard Schema package in your app code. Just pass your existing schema object to validation.

Pass a synchronous Standard Schema-compatible validator to the Form constructor. Zod works out of the box. Errors are mapped to fields by key:

import { z } from 'zod';
import { Form } from 'fluid-primitives/form';

const validation = 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,
    validation,
    onSubmit: async ({ api, post }) => {
        const response = await post(api.getAction());
        return response.ok;
    },
});

Any validator that implements the Standard Schema interface can be passed here without an adapter.

Validation 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 a schema library, pass a synchronous validation callback. It receives the current values object and should return the same flat field-keyed error shape used by server validation responses. Missing value properties are filled automatically with the current field value:

import { Form } from 'fluid-primitives/form';

const form = new Form({
    ...data.props,
    validation: ({ values }) => {
        const errors: Record<string, { messages: string[] }> = {};
        const email = values.get('email');

        if (typeof email !== 'string' || !email.includes('@')) {
            errors.email = { messages: ['Please enter a valid email address.'] };
        }

        return errors;
    },
    onSubmit: async ({ api, post }) => {
        const response = await post(api.getAction());

        return response.ok;
    },
});

Validation callbacks must be synchronous. Use onSubmit for async checks.

Async Validation During Submission

If a validation rule depends on async work inside onSubmit, prefer post() when the endpoint accepts the same form payload shape. If that endpoint returns a non-422 response, you can still map it to field errors yourself:

import { Form } from 'fluid-primitives/form';

const form = new Form({
    ...data.props,
    onSubmit: async ({ post }) => {
        const response = await post('/email-check');

        if (response.status === 409) {
            return {
                email: { messages: ['This email address is already registered.'] },
            };
        }

        if (!response.ok) {
            return false;
        }

        return true;
    },
});

Returning field errors from onSubmit 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:

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 via validation.

Event Registration

Attendance Mode
Your full name as it will appear on the ticket.
Your email address will be used for registration confirmation and updates.
Only used if we need to contact you about your registration.
Accessibility Needs