Building forms with the FormModel system — field types, renderers, layout, validation, conditional rules, computed fields, and dynamic zones. Use this skill when the developer needs to define form fields with the builder API, choose renderers, build layouts with tabs/rows/separators, add validation (Zod or imperative), use conditional visibility/disable rules, create computed fields, or work with object fields and templates (dynamic zones).
Building forms with the FormModel system — field types, renderers, layout, validation, conditional rules, computed fields, and dynamic zones. Use this skill when the developer needs to define form fields with the builder API, choose renderers, build layouts with tabs/rows/separators, add validation (Zod or imperative), use conditional visibility/disable rules, create computed fields, or work with object fields and templates (dynamic zones).
Form Model
TL;DR
The Form Model is Webiny's declarative form system. Define fields with a fluent builder API (fields.text(), fields.datetime(), etc.), arrange them with a layout builder (layout.row(), layout.tabs(), etc.), and validate with Zod schemas or imperative rules. Fields support conditional visibility, computed values, reactive context from other fields (.context()), and deeply nested object/list structures with templates (dynamic zones).
Field Types
All fields are created via the fields registry callback. Each builder method returns a chainable builder.
File picker returning URL only (default for fileUrl)
objectAccordionSingle
object
{ open?: boolean }
Single object in accordion (default for object)
objectAccordionMultiple
object (list)
{ open?, container?, itemTitle?, addItemLabel? }
List of objects in accordions (auto for .list())
dynamicZone
object (templates)
{ container?: boolean }
Template picker zone (auto for .template())
passthrough
object
—
Renders child fields inline without wrapper
keyValueTags
object (list)
{ addItemLabel?: string }
Key-value tag pairs
hidden
any
—
Hidden field (no UI rendered, but field stays visible in VM)
passwordInput
password
—
Password input (default for password)
permissions
permissions
—
Permissions editor (default for permissions)
rolesMultiSelect
rolesMultiSelect
—
Roles multi-select (default for rolesMultiSelect)
Automatic Renderer Switching
Calling .options() on text/number fields switches to select
Calling .list() on datetime switches to dateTimeInputs
Calling .list() on object switches to objectAccordionMultiple
Calling .template() on object switches to dynamicZone
Layout
Layout controls how fields are arranged in the UI. Defined via the layout callback.
Basic Layout
layout: layout => [
layout.row("title"), // single field row
layout.row("firstName", "lastName"), // two fields side by side
layout.separator() // visual divider
];
All field callbacks (computed, computedUntilDirty, hiddenWhen, disabledWhen, requiredWhen, options, context) receive a single { field, form } object:
form — the root IFormModel for absolute field access (e.g., form.field("title"))
field — a navigator scoped to the current field. Call .parent() to get the containing object, then .field(name) to access fields at that level. Chain .parent() for higher levels.
// Relative: access a sibling within the same object
fields
.object()
.renderer("passthrough")
.fields(f => ({
label: f.text().defaultValue("Hello"),
slug: f.text().computedUntilDirty(({ field }) =>String(field.parent().field("label").getValue() || "")
.toLowerCase()
.replace(/\s+/g, "-")
)
}));
// Absolute: access a root-level field
fields.text().computedUntilDirty(({ form }) =>String(form.field("title").getValue() ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
);
// Multi-level traversal: parent().parent() goes up two levels
inner.file().context(({ field }) => ({
title: field.parent().parent().field("title").getValue()
}));
Conditional Visibility / Disable (callback form)
For dynamic visibility and disabled state that depends on other field values:
// Hide a field based on a sibling value (inside an object)
fields
.text()
.label("Details")
.hiddenWhen(({ field }) => field.parent().field("mode").getValue() !== "advanced");
// Disable based on a root-level field
fields
.text()
.label("Name")
.disabledWhen(({ form }) =>Boolean(form.field("locked").getValue()));
Both hiddenWhen and disabledWhen accept (params: IFieldCallbackParams) => boolean. Multiple calls chain — any returning true triggers the effect.
Computed Fields
// Always computed — recalculated when dependencies change
fields
.text()
.label("Full Name")
.computed(({ form }) =>`${form.field("first").getValue()}${form.field("last").getValue()}`);
// Computed until the user edits the field manually
fields
.text()
.label("Slug")
.computedUntilDirty(({ form }) => {
const name = String(form.field("title").getValue() ?? "");
return name.trim().toLowerCase().replace(/\s+/g, "-");
});
Cross-Field Interaction
Use .afterChange() to react to value changes and modify other fields:
Use .context() to push data from other fields into a field's VM. The renderer reads it via field.context — no hooks, no reaching up to the parent form. The callback is MobX-reactive: only the specific fields accessed inside it trigger re-renders.
The callback receives { field, form } — the same IFieldCallbackParams used by all other callbacks (see Callback Parameters).
// Normal submit — validates first, returns false if invalidconst data = await form.submit();
// Skip validation — returns data immediatelyconst data = await form.submit({ skipValidation: true });
FormVM
The IFormVM exposes reactive state for the UI:
form.vm.layout; // LayoutNodeVM[] — resolved layout nodes
form.vm.errors; // IFormError[] — current validation errors
form.vm.hasErrors; // boolean — shorthand for errors.length > 0
form.vm.isDirty; // boolean — any field changed from initial value
form.vm.isValid; // boolean | null — null until first validation
form.vm.submitCount; // number — increments on each submit attempt
form.vm.focusField(path); // scroll to and focus a field
form.vm.getData(); // current form data snapshot
form.vm.setData(); // replace all form data