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

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

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

Install Zod separately:

npm install zod

Pass a Zod schema to the Form constructor. Validation runs on blur and before submission. 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:

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.

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