Form Validation
Who validates (FE, BE, hybrid), where errors appear, and copy-paste recipes for f-ui Formily forms.
Use this page when wiring fill-form validation — required rules, reveal timing, where errors live (summary vs table rollup), async checks, and server issue mapping. Component APIs live on Form (Formily). Table cell vs summary ownership also lives on Object Messaging And Table Chrome.
When To Use
- You need to choose frontend preview vs backend authority vs hybrid.
- You need to know when a field error appears (
submit/touch/always) — walk When Errors Show with the numbered Try steps. - You are composing header fields + Editable Table and need to know whether Form Error Summary stays on — walk Where Errors Appear.
- You are about to set
revealErrors,focusOnInvalid, orapplyFormIssues. - You want a named recipe instead of hunting through Formily API tables.
- You are mixing pretty / readOnly / editable on one Edit form, or wiring an approver inbox (Display until Unlock) — walk Mixed / Approver Lock. Lock mark placement and field vs surface scope: Field Lock Affordance.
1. Who Validates
| Class | Use when | Do not |
|---|---|---|
| FE-only | Required, format, length, local cross-field — fully knowable in the browser | Treat as a security boundary |
| BE-only | Authz, concurrency, credentials, payment channel, file content safety | Skip server checks because the UI looked valid |
| Hybrid | Uniqueness, domain blacklist, budgets, anything that needs a round-trip and a good preview | Trust async FE uniqueness alone |
Locks: Server is authoritative for business invariants. Default hybrid = FE gate → submit → map FormIssue[] onto fields + Form Error Summary. Security-sensitive failures stay generic at form root (no “user does not exist” on a field).
2. Where Errors Appear
One error class → one aggregate owner. Field/cell chrome can sit next to that owner. Two red lists of the same paths is a bug.
| Surface | Owns | Does not own |
|---|---|---|
| Field (Form Item) | Header / panel field messages | Table cell paths |
| Form Error Summary | Root / system (path omitted), header fields, array-root (lineItems with no .cell) | Editable Table cell descendants (lineItems.0.sku) |
| Editable Table cell + region rollup | That table’s cell issues + jump | Header fields; other tables’ cells |
| Message Popover | Multi-region / object readiness after Validate or blocked Submit | Draft auto-open; replacing cell chrome |
Full visual language (cell value-state, toolbar, popover): Object Messaging And Table Chrome. Form Error Summary and the table region rollup share the same Primer Banner card (bg-card, 1px border, small status mark, foreground heading). They must not list the same cell paths.
When To Use Form Error Summary
Use it when the operator needs an index of non-table problems after a failed Submit (GOV.UK Error summary twin). One error class → one aggregate owner: Banner = header / array-root / root; rollup = cells; popover = multi-region object.
| Situation | Summary? | How |
|---|---|---|
| Ordinary form, no Editable Table | Yes | Default scope="all" — lists every visible field + root issue, jump links |
| Form Page with header fields and line items | Yes (keep default) | FormPage scope="all"; kit strips cell paths; header + array-root stay |
| Table-only region (no header / array-root / system rules) | No | errorSummary={false} — the rollup is the aggregate |
| System / unknown throw only | Root-only | scope="form" (path == null only — not header fields, not lineItems) |
| Login / credentials | Root-only | One generic root message. Do not mark which field was wrong — Security Sensitive Failures |
| Query Filter search | Root-only | Shell default scope="form" |
| Modal with a table | Root-only by default | Modal default scope="form" + focusOnInvalid="first-field"; cells on the rollup |
| Steps Form step that is an Editable Table | No on that step | errorSummary={false} — rollup owns cells |
| Many header errors | Yes | Banner caps height + thin scroll; jump still reaches the field — Many Header Field Errors |
| Many invalid cells | Array-root only | Banner may show Some rows are invalid once. Cells stay in the rollup |
| Warning | No | Field (and optional Message Popover). Does not block Submit by itself |
| Object page, several regions, footer Validate | Optional | Message Popover is the object index; do not also dump every cell into a page summary |
scope="form" is not “everything except table cells.” It is only issues with no path.
Table Only — No Summary
No header rules. Mount no FormErrorSummary. After Submit, only the rollup lists cell problems.
Try this:
- The demo already submitted empty line 2. Confirm there is no page-level red banner above the table.
- Confirm the rollup under the table lists the bad SKU / Qty.
- Fix the cells. Confirm the rollup disappears.
Scope Form Vs All
Toggle scope. Empty Customer is a header path. “Could not reach the server” has no path.
Try this:
- Leave the switch off (
scope="form"). Click Submit empty. Confirm: Customer’s inline error may show after submit, but the summary stays empty (no root issue yet). - Type
Adain Customer. Click Submit. Confirm the summary shows only Could not reach the server. - Turn the switch on (
scope="all"). Clear Customer, Submit. Confirm the summary now lists the Customer required message. Fill Customer and Submit again — root system message and no extra cell dump.
Object Page — Message Popover
Several regions + footer Validate: Message Popover is the object index. Do not also dump every cell into a page summary.
Try this:
- Click Validate (or Submit). Confirm the popover opens with mixed error + warning items.
- Confirm this is a region/object list — not Form Error Summary repeating SKU strings.
Purchase request · PR-1042
Draft edits stay quiet. Click Validate to open the object message list.
"use client";
import { useState } from "react";
import {
MessagePopover,
type MessageItem,
} from "@/components/f-ui/message-popover/message-popover";
import { FooterToolbar } from "@/components/f-ui/page/footer-toolbar";
import { Button } from "@/components/ui/button";
const MESSAGES: MessageItem[] = [
{
id: "e1",
severity: "error",
title: "Missing unit price",
subtitle: "Line 2 · Unit price",
group: "Line items",
description: "Enter a price greater than 0.",
},
{
id: "w1",
severity: "warning",
title: "Qty looks high",
subtitle: "Line 1 · Qty",
group: "Line items",
description: "Confirm quantity on line 1 before submit.",
},
];
/**
* Footer left = Message Popover; finalizing actions on the right.
* Open after Validate / blocked Submit — not on every draft keystroke.
*/
export function ObjectMessagingFooterPopoverDemo() {
const [open, setOpen] = useState(false);
const [items] = useState(MESSAGES);
return (
<div className="overflow-hidden rounded-xl border bg-card">
<div className="space-y-2 border-b px-6 py-3">
<p className="text-sm font-medium">Purchase request · PR-1042</p>
<p className="text-xs text-muted-foreground">
Draft edits stay quiet. Click Validate to open the object message list.
</p>
<Button
type="button"
variant="outline"
onClick={() => setOpen(true)}
>
Validate
</Button>
</div>
<FooterToolbar
className="border-t-0"
extra={
<MessagePopover
items={items}
open={open}
onOpenChange={setOpen}
/>
}
>
<Button type="button" variant="outline">
Save draft
</Button>
<Button
type="button"
onClick={() => setOpen(true)}
>
Submit
</Button>
</FooterToolbar>
</div>
);
}Steps Form — Table Step
A step that is an Editable Table: errorSummary={false}. Next still validates; the rollup owns cells.
Try this:
- Leave the SKU empty. Click Next.
- Confirm there is no Form Error Summary banner. Confirm the rollup lists the cell.
- Fix the SKU, Next, then Submit on Done.
How Each Error Shows
| Error class | Example | After failed Submit you should see |
|---|---|---|
| Header / panel field | Customer required | Form Item chrome and a summary jump link |
| Array-root | “Add at least one line” on lineItems | Summary — not a fake “Row N” rollup line |
| Table cell | Line 2 SKU required | Cell value-state + rollup. Not in Form Error Summary |
| Root / system | “Could not reach the server” | Summary scope="form" or scope="all" |
| Server field issue on a header | applyFormIssues { path: "customer" } | Immediate Form Item chrome + summary |
| Server field issue on a cell | applyEditableArrayIssues | Cell + rollup — never applyFormIssues under a mounted table |
| Warning | severity: "warning" | Field (and optional Message Popover). Not in Form Error Summary. Does not block Submit by itself |
Table cells do not follow FormItem Konjević timing. They stay hide-while-focused until reveal / Submit.
This demo already failed Submit: header, array-root, cells, root/system, and a warning — each on the locked surface.
Try this:
- In Form Error Summary, find Customer (header), Some rows are invalid (array-root), and the long Could not reach the server line (root). Confirm Memo’s warning is on the field, not in the Banner.
- Confirm SKU / Qty strings are not in the summary — they are under the table rollup.
- Fill Customer. Confirm that summary row drops; the root message and rollup stay until those are fixed.
Array Root Stays In The Summary
Empty table is an array-root rule, not a cell.
Try this:
- Type a Customer name so header validation passes.
- Leave no lines. Click Submit.
- Confirm Form Error Summary lists Add at least one line. Confirm there is no rollup Row N for that message.
- Click Add row, Submit again. Confirm the array-root message is gone and cell errors (if the new row is empty) move to the rollup.
Shell Defaults
| Shell | Form Error Summary | Table rollup | Focus after invalid Submit |
|---|---|---|---|
| Form (primitive) | Host composes | On with Editable Table | false unless you set it |
| Form Page | Default on, scope="all" | On | "summary" |
| Modal Form | Default scope="form" | On | "first-field" |
| Steps Form | Keep on when the step has header/array-root; false when cells-only | On | Shell default |
| Query Filter | scope="form" | — | "first-field" |
Modal With A Table
Dialog summary is root-only. Cell errors must not become a long dialog essay.
Try this:
- Click Open line editor. Click the dialog Submit with the empty SKU.
- Confirm the rollup lists the cell. Confirm the dialog summary does not list SKU required (no root issue).
- Fix the line and Submit. Confirm the dialog can close.
Header Fields Plus Editable Table
This is the usual create/edit page: customer / dates on top, line items in an embedded table, one Submit.
Try this:
- Leave Customer empty. Leave line 2 SKU empty and Qty
0. Click Submit. - Confirm Form Error Summary lists Customer (header). Confirm it does not list “SKU required” / “must be > 0” — those belong under the table.
- Confirm the rollup under the table lists the bad line(s) and jump links scroll/focus the cell.
- Type a customer name (no extra blur needed once shown). Confirm the summary row for Customer disappears; the rollup still lists line errors until those cells are valid.
- Click a rollup jump. Confirm the cell shows value-state on focus (not a second Input border, not a hover tooltip).
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";
type Line = { id: string; sku: string; qty: number };
type Values = {
customer: string;
lines: Line[];
};
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input], required: true },
},
qty: {
...f.number({ label: "Qty" }),
size: 96,
field: {
component: [NumberInput, { surface: "tableCell", min: 1 }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
export function FormValidationHeaderPlusEditableTableDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
customer: "",
lines: [
{ id: "l1", sku: "SKU-104", qty: 2 },
{ id: "l2", sku: "", qty: 0 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField
name="customer"
kind="text"
label="Customer"
required
/>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={columns}
getRowId={(row) => row.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
qty: 1,
}),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Capstone with cross-row rules: Editable Table — Complete Order Entry. FormPage wiring: Form Page — Embedded Editable Table.
Extreme Cases
These demos are already in the failed-Submit state (or one click away) so you can see a long list, not imagine it.
Many Header Field Errors
16 empty required fields. The summary is the index: it scrolls (max-height + thin scrollbar), and jump links do not stop at “first three.” Submit stays enabled.
Try this:
- Scroll the summary — every field is listed, not truncated.
- Click a jump link near the bottom (e.g. External ref). Confirm the page moves to that field.
- Fill one field. Confirm that field’s row disappears from the summary. Do not expect Submit to disable.
"use client";
import type { Form as FormilyForm } from "@formily/core";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { DesignCompare } from "@/demos/_design/design-compare";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { f } from "@/components/f-ui/field-types/catalog";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import {
formErrorBannerBodyInnerClassName,
formErrorBannerHeadingClassName,
formErrorBannerLinkClassName,
formErrorBannerRootClassName,
} from "@/components/f-ui/formily/internals/form-error-banner-chrome";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { setFormComposing } from "@/components/f-ui/formily/internals/form-state";
import { FormPage } from "@/components/f-ui/form-page/form-page";
import { ModalForm } from "@/components/f-ui/modal-form/modal-form";
import { PageFooterSlotProvider } from "@/components/f-ui/page/page-footer-slot";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
type Line = { id: string; sku: string; qty: number };
const lineColumns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input], required: true },
},
qty: {
...f.number({ label: "Qty" }),
size: 88,
field: {
component: [NumberInput, { surface: "tableCell", min: 1 }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
});
function submitCatch<T extends object>(form: FormilyForm<T>) {
void form.submit(() => undefined).catch(() => undefined);
}
const MANY_HEADER_FIELDS = [
"Customer",
"PO number",
"Buyer",
"Ship-to",
"Bill-to",
"Incoterms",
"Currency",
"Payment terms",
"Warehouse",
"Carrier",
"Contact name",
"Contact email",
"Cost center",
"Project code",
"Memo",
"External ref",
] as const;
/** 16 empty required header fields — summary is the index and it scrolls. */
export function FormValidationManyHeaderErrorsDemo() {
const initialValues = useMemo(() => {
const values: Record<string, string> = {};
for (const label of MANY_HEADER_FIELDS) {
values[label.replace(/\s+/g, "")] = "";
}
return values;
}, []);
const form = useMemo(
() => createForm({ initialValues }),
[initialValues],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{MANY_HEADER_FIELDS.map((label) => (
<FormField
key={label}
name={label.replace(/\s+/g, "")}
kind="text"
label={label}
required
/>
))}
</div>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
function manyLines(count: number): Line[] {
return Array.from({ length: count }, (_, i) => ({
id: `r${i + 1}`,
sku: i % 3 === 0 ? `SKU-${100 + i}` : "",
qty: i % 3 === 0 ? 2 : 0,
}));
}
/** 24 lines, most invalid — rollup scrolls; summary does not dump every cell. */
export function FormValidationManyCellErrorsDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; lines: Line[] }>({
initialValues: {
customer: "Northwind",
lines: manyLines(24),
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ view: { mode: "all", maxHeight: 220 } }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** Cells-only page: no FormErrorSummary — rollup is the aggregate. */
export function FormValidationTableOnlyNoSummaryDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "a", sku: "SKU-1", qty: 1 },
{ id: "b", sku: "", qty: 0 },
],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** `scope="form"` keeps only path-less issues — not header fields. */
export function FormValidationSummaryScopeFormDemo() {
const form = useMemo(
() =>
createForm<{ customer: string }>({
initialValues: { customer: "" },
}),
[],
);
const [scope, setScope] = useState<"all" | "form">("form");
return (
<div className="w-full max-w-md space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={scope === "all"}
onCheckedChange={(on) => setScope(on ? "all" : "form")}
/>
<span className="text-muted-foreground">
{scope === "all" ? 'scope="all"' : 'scope="form" (root only)'}
</span>
</label>
<Form
key={scope}
form={form}
onSubmit={() => ({
status: "error" as const,
issues: [
{
source: "system" as const,
message: "Could not reach the server",
},
],
})}
>
<FormErrorSummary scope={scope} />
<FormField name="customer" kind="text" label="Customer" required />
<FormActions>
<Button type="submit">
Submit empty
</Button>
</FormActions>
</Form>
</div>
);
}
/** Array-root “add at least one line” stays in the summary, not a fake Row N. */
export function FormValidationArrayRootDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; lines: Line[] }>({
initialValues: { customer: "", lines: [] },
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={(values) => {
if (values.lines.length === 0) {
return {
status: "error" as const,
issues: [
{
source: "submit" as const,
path: "lines",
message: "Add at least one line",
},
],
};
}
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
recordCreator={{
record: () => ({
id: crypto.randomUUID(),
sku: "",
qty: 1,
}),
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** Defer the loud rollup while drafting. */
export function FormValidationDraftRollupDemo() {
const [showRollup, setShowRollup] = useState(false);
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "d1", sku: "", qty: 0 },
{ id: "d2", sku: "", qty: 0 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch checked={showRollup} onCheckedChange={setShowRollup} />
<span className="text-muted-foreground">
showErrorRollup={String(showRollup)}
</span>
</label>
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
showErrorRollup={showRollup}
/>
<FormActions>
<Button type="submit">
Validate
</Button>
</FormActions>
</Form>
</div>
);
}
/** Two tables → two rollups. */
export function FormValidationTwoTablesDemo() {
const form = useMemo(
() =>
createForm<{ charges: Line[]; notes: Line[] }>({
initialValues: {
charges: [{ id: "c1", sku: "", qty: 0 }],
notes: [{ id: "n1", sku: "", qty: 0 }],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-6">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<p className="text-muted-foreground text-sm font-medium">Charges</p>
<EditableTable<Line>
name="charges"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<p className="text-muted-foreground text-sm font-medium">Notes</p>
<EditableTable<Line>
name="notes"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** Search hides an invalid row — Submit still fails; jump reveals it. */
export function FormValidationHiddenRowSearchDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "ok", sku: "WIRE-1", qty: 4 },
{ id: "bad", sku: "", qty: 0 },
{ id: "ok2", sku: "WIRE-2", qty: 2 },
],
},
}),
[],
);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ search: true }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** Dual scream vs one owner — wrong is stacked Banners of the same SKU, not old pink Alert chrome. */
export function FormValidationDualScreamCompareDemo() {
return (
<DesignCompare
wrong={
<div className="space-y-2 text-sm">
<div className={formErrorBannerRootClassName} role="presentation">
<p className={formErrorBannerHeadingClassName}>There is a problem</p>
<ul className={cn(formErrorBannerBodyInnerClassName, "space-y-1")}>
<li>
<span className={formErrorBannerLinkClassName}>SKU is required</span>
</li>
<li>
<span className={formErrorBannerLinkClassName}>
Qty must be > 0
</span>
</li>
</ul>
</div>
<div className={formErrorBannerRootClassName}>
<p className={formErrorBannerHeadingClassName}>
1 row(s) need attention
</p>
<p
className={cn(
formErrorBannerBodyInnerClassName,
formErrorBannerLinkClassName,
"text-xs",
)}
>
Row 2 · SKU: SKU is required
</p>
</div>
</div>
}
right={<FormValidationTableOnlyNoSummaryDemo />}
/>
);
}
export function FormValidationImeSimulateDemo() {
const form = useMemo(
() => createForm<{ workEmail: string }>({ initialValues: { workEmail: "" } }),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
revealErrors="touch"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="workEmail"
kind="email"
label="Work email"
required
/>
<FormActions>
<Button
type="button"
variant="outline"
onClick={() => setFormComposing(form, true)}
>
Start composing
</Button>
<Button
type="button"
variant="outline"
onClick={() => setFormComposing(form, false)}
>
End composing
</Button>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
export function FormValidationSecurityRootDemo() {
const form = useMemo(
() =>
createForm<{ email: string; password: string }>({
initialValues: { email: "", password: "" },
}),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
onSubmit={() => ({
status: "error" as const,
issues: [
{
source: "server" as const,
message: "Email or password is incorrect.",
},
],
})}
>
<FormErrorSummary title="Sign in failed" />
<FormField name="email" kind="email" label="Email" required />
<FormField
name="password"
kind="password"
label="Password"
required
/>
<FormActions>
<Button type="submit">
Sign in
</Button>
</FormActions>
</Form>
</div>
);
}
type ModalLines = { lines: Line[] };
export function FormValidationModalTableDemo() {
return (
<ModalForm<ModalLines>
trigger={<Button variant="outline">Open line editor</Button>}
title="Line items"
description="Dialog default summary is root-only. Cell errors stay on the rollup."
initialValues={{
lines: [{ id: "m1", sku: "", qty: 0 }],
}}
onFinish={async () => {
toast.success("Saved");
}}
>
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</ModalForm>
);
}
const ALL_CLASS_ISSUES: FormIssue[] = [
{
source: "system",
message:
"Could not reach the server. Retry in a few minutes. This line is intentionally long so you can see the summary wrap and scroll instead of truncating.",
},
{
source: "server",
path: "memo",
severity: "warning",
message: "Memo is empty — the default text will be used.",
},
];
function mergeAfterInvalidSubmit<T extends object>(
form: FormilyForm<T>,
extra: FormIssue[],
) {
void form
.submit(() => undefined)
.catch(() => {
applyFormIssues(form, extra, { mode: "merge" });
});
}
/**
* One Submit: header, array-root, cells, root/system, warning — each on its
* locked surface (summary vs rollup).
*/
export function FormValidationAllClassesDemo() {
const form = useMemo(
() =>
createForm<{ customer: string; memo: string; lines: Line[] }>({
initialValues: {
customer: "",
memo: "",
lines: [
{ id: "ok", sku: "SKU-1", qty: 2 },
{ id: "bad", sku: "", qty: 0 },
],
},
}),
[],
);
useEffect(() => {
mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="customer" kind="text" label="Customer" required />
<FormField name="memo" kind="text" label="Memo" />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
<FormActions>
<Button type="submit">
Submit again
</Button>
</FormActions>
</Form>
</div>
);
}
const VOLUME_HEADERS = [
"Customer",
"PO number",
"Buyer",
"Ship-to",
"Warehouse",
"Carrier",
"Cost center",
"External ref",
] as const;
/** Header volume + cell volume + root + warning in one failed Submit. */
export function FormValidationCombinedVolumeDemo() {
const initialValues = useMemo(() => {
const values: Record<string, string | Line[]> = {};
for (const label of VOLUME_HEADERS) {
values[label.replace(/\s+/g, "")] = "";
}
values.memo = "";
values.lines = manyLines(16);
return values;
}, []);
const form = useMemo(
() => createForm({ initialValues }),
[initialValues],
);
useEffect(() => {
mergeAfterInvalidSubmit(form, ALL_CLASS_ISSUES);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{VOLUME_HEADERS.map((label) => (
<FormField
key={label}
name={label.replace(/\s+/g, "")}
kind="text"
label={label}
required
/>
))}
</div>
<FormField name="memo" kind="text" label="Memo" />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
features={{ view: { mode: "all", maxHeight: 200 } }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
type Claim = { id: string; qty: number; note: string };
type NestedLine = {
id: string;
sku: string;
claims: Claim[];
};
const nestedParentColumns = defineEditableColumns<NestedLine>({
sku: {
...f.text({ label: "SKU" }),
size: 140,
field: { component: [Input], required: true },
},
});
const nestedClaimColumns = defineEditableColumns<Claim>({
qty: {
...f.number({ label: "Claim qty" }),
size: 110,
field: {
component: [NumberInput, { surface: "tableCell", min: 1 }],
required: true,
validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
},
},
note: {
...f.text({ label: "Note" }),
field: { component: [Input] },
},
});
/** Parent cell + nested child cell — two rollup languages, still not in the page summary. */
export function FormValidationNestedTableErrorsDemo() {
const form = useMemo(
() =>
createForm<{ lines: NestedLine[] }>({
initialValues: {
lines: [
{
id: "p1",
sku: "A-100",
claims: [{ id: "c1", qty: 0, note: "" }],
},
{
id: "p2",
sku: "",
claims: [],
},
],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-2xl space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<EditableTable<NestedLine>
name="lines"
variant="embedded"
columns={nestedParentColumns}
getRowId={(row) => row.id}
nested={{
path: "claims",
columns: nestedClaimColumns,
getChildRowId: (c) => c.id,
defaultExpanded: true,
recordCreator: {
record: () => ({
id: crypto.randomUUID(),
qty: 1,
note: "",
}),
},
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
/** Server / submit / system issues bypass the reveal gate — no Submit, no blur. */
export function FormValidationServerImmediateDemo() {
const form = useMemo(
() =>
createForm<{ email: string; warehouse: string }>({
initialValues: { email: "ada@example.com", warehouse: "" },
}),
[],
);
return (
<div className="w-full max-w-md space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" kind="email" label="Email" required />
<FormField name="warehouse" kind="text" label="Warehouse" required />
<FormActions>
<Button
type="button"
variant="outline"
onClick={() => {
applyFormIssues(form, [
{
source: "server",
path: "email",
message: "Email is already registered.",
},
]);
}}
>
Apply server issue
</Button>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
export function FormValidationFormPageShellDemo() {
const [slot, setSlot] = useState<HTMLElement | null>(null);
const form = useMemo(
() =>
createForm<{ title: string; warehouse: string; lines: Line[] }>({
initialValues: {
title: "",
warehouse: "",
lines: [{ id: "p1", sku: "", qty: 0 }],
},
}),
[],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<PageFooterSlotProvider target={slot}>
<div className="overflow-hidden rounded-xl border bg-card">
<FormPage
form={form}
footerVisible
density="compact"
className="p-4"
onFinish={() => {
toast.success("Saved");
}}
>
<FormField name="title" kind="text" label="Order title" required />
<FormField name="warehouse" kind="text" label="Warehouse" required />
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</FormPage>
<div ref={setSlot} />
</div>
</PageFooterSlotProvider>
);
}
export function FormValidationStepsTableDemo() {
return (
<div className="w-full max-w-2xl">
<StepsForm
initialValues={{
lines: [{ id: "s1", sku: "", qty: 0 }],
}}
errorSummary={false}
onFinish={async () => {
toast.success("Queued");
}}
>
<StepsForm.Step name="lines" title="Lines">
<EditableTable<Line>
name="lines"
variant="embedded"
columns={lineColumns}
getRowId={(row) => row.id}
/>
</StepsForm.Step>
<StepsForm.Step name="done" title="Done">
<p className="text-muted-foreground text-sm">
Last step — write API would run on Submit here.
</p>
</StepsForm.Step>
</StepsForm>
</div>
);
}
const I18N_COPY = {
en: {
required: "Account code is required",
pattern: "Use letters and digits only",
},
zh: {
required: "必须填写账号代码",
pattern: "只能使用字母和数字",
},
} as const;
export function FormValidationI18nDemo() {
const [lang, setLang] = useState<"en" | "zh">("en");
const copy = I18N_COPY[lang];
const form = useMemo(
() =>
createForm<{ email: string; code: string }>({
initialValues: { email: "", code: "" },
}),
[lang],
);
useEffect(() => {
submitCatch(form);
}, [form]);
return (
<div className="w-full max-w-md space-y-4">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={lang === "zh"}
onCheckedChange={(on) => setLang(on ? "zh" : "en")}
/>
<span className="text-muted-foreground">
Custom copy: {lang === "zh" ? "zh" : "en"} (email format follows the
docs site language)
</span>
</label>
<Form
key={lang}
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" kind="email" label="Email" required />
<FormField
name="code"
kind="text"
label="Account code"
required
validator={(value) => {
if (!value) return copy.required;
if (!/^[A-Za-z0-9]+$/.test(String(value))) return copy.pattern;
return undefined;
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}
Many Invalid Line Cells
24 lines, most invalid. Summary may show the array-root Some rows are invalid (one line). The rollup lists cells and scrolls. It does not dump 40 SKU messages into the page summary.
Try this:
- Confirm Form Error Summary does not repeat every SKU/Qty string.
- Scroll the rollup under the table. Confirm many
Row N · SKU/Qtylinks. - Click a jump for a row that is below the table fold. Confirm the table scrolls and the cell focuses.
- Fill Customer is already set — this is a table-volume problem, not a missing header.
Invalid Rows Hidden By Search
Submit validates the full array, not the visible page.
Try this:
- In the table search box, type
WIRE. Confirm the empty-SKU row disappears from the view. - Click Submit. Confirm it still fails and the rollup still lists the hidden row.
- Click the rollup jump. Confirm search clears and the bad row is focused.
Drafting — Rollup Off
showErrorRollup={false} until Validate so a large grid stays quiet.
Try this:
- Leave the switch off. Click Validate. Confirm cells can reveal but no rollup band appears.
- Turn the switch on. Click Validate again. Confirm the rollup appears.
Two Editable Tables
Each table owns its own rollup.
Try this:
- Confirm two rollups (Charges and Notes), not one merged page list of cells.
- Fix only Charges. Confirm the Notes rollup remains.
Dual Scream (Forbidden)
Same cell copy must not appear as a page summary and a rollup.
Try this:
- Left: stacked essays listing SKU/Qty and “N row(s) need attention.”
- Right: rollup only (kit). That is the ship rule.
Wrong
There is a problem
- SKU is required
- Qty must be > 0
1 row(s) need attention
Row 2 · SKU: SKU is required
Right
Combined Volume — Headers Plus Cells Plus Root
The usual “the whole page is red” case: many required headers, many invalid lines, plus a root/system message and a warning. Summary scrolls; rollup scrolls; cells stay out of the summary.
Try this:
- Scroll the summary. Count should include the eight headers + root + warning + array-root — not 16× SKU strings.
- Scroll the rollup. Confirm cell links, not a second copy of Customer required.
- Click a jump near the bottom of the summary (e.g. External ref). Confirm the field is reached.
Nested Child Table
Parent SKU + nested claim qty. Child errors fold into the parent rollup (expand + jump). Still not a page-summary essay.
Try this:
- Confirm row 1 is expanded and the nested Claim qty is invalid.
- Confirm row 2 empty SKU is on the parent rollup.
- Confirm Form Error Summary does not list
claims.0.qty.
Precedent: GOV.UK Error summary for page field indexes; Fiori for not repeating every table cell at page root. Kit lock: Object Messaging — Validation Surface Matrix.
3. When Errors Show
Pick a policy on <Form revealErrors>. Submit still validates every rule — this only changes when chrome appears. Once a field is showing an error, the message stays until that field is valid (including while focused). New errors after a failed Submit wait for blur.
| Policy | First paint | After an error is showing | Use when |
|---|---|---|---|
submit (default) | Failed Submit (or mapped field issues) | Clears as soon as the value is valid. New invalid fields wait for blur | Long create / edit forms |
touch | Blur of an invalid field (empty required included) | Same clear-as-you-fix | Short forms, settings, where leaving a field is a commit |
always | As soon as the field is invalid — no Submit wait | Live | Password strength, character count. Not the long-form default |
First keystroke / first focus of a fresh field never paints under submit or touch. Hosts that used touch as “show while typing on first entry” must switch to always. Walk every numbered case in this section.
Table cells are a different language (hide while focused). Do not copy these FormItem steps onto Editable Table.
Policy Overview
Toggle submit / touch / always on one required field. Each mode’s helper text is the contract for that policy.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { RevealErrorsPolicy } from "@/components/f-ui/formily/internals/reveal-errors";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Values = { title: string };
const MODES: RevealErrorsPolicy[] = ["submit", "touch", "always"];
export function FormValidationRevealErrorsModesDemo() {
const [mode, setMode] = useState<RevealErrorsPolicy>("submit");
const form = useMemo(
() => createForm<Values>({ initialValues: { title: "" } }),
[mode],
);
return (
<div className="w-full max-w-md space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">revealErrors</span>
<Select
value={mode}
onValueChange={(v) => setMode(v as RevealErrorsPolicy)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Form
key={mode}
form={form}
revealErrors={mode}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="title"
kind="text"
label="Order title"
required
description={
mode === "submit"
? "Submit empty to see the error, then type a title — it should clear without blurring."
: mode === "touch"
? "Focus, leave empty, blur to see the error. Typing before blur should not paint."
: "always paints as soon as the field is invalid."
}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Submit Quiet Until Submit
Default policy (revealErrors omitted is the same as "submit"). Typing and leaving the field must not shame incomplete input.
Try this:
- Click Order title, type
x, click outside the field. - Confirm: no red border, no error text, no summary.
- The value is still invalid — Submit in the next demo is what reveals it.
"use client";
import type { Form } from "@formily/core";
import { useEffect, useMemo, type ReactNode } from "react";
import { toast } from "sonner";
import { Form as FuiForm } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { RevealErrorsPolicy } from "@/components/f-ui/formily/internals/reveal-errors";
import { Button } from "@/components/ui/button";
type TitleValues = { title: string };
type PairValues = { title: string; extra: string };
type EmailValues = { workEmail: string };
function TimingForm<T extends object>({
revealErrors,
initialValues,
children,
}: {
revealErrors?: RevealErrorsPolicy;
initialValues: T;
children: (form: Form<T>) => ReactNode;
}) {
const form = useMemo(
() => createForm<T>({ initialValues }),
// Fresh instance per mount; demos remount via the page, not via deps.
// eslint-disable-next-line react-hooks/exhaustive-deps -- demo isolation
[],
);
return (
<div className="w-full max-w-md space-y-4">
<FuiForm
form={form}
revealErrors={revealErrors}
onSubmit={() => {
toast.success("Submitted");
}}
>
{children(form)}
</FuiForm>
</div>
);
}
function SubmitRow({
form,
showReset,
}: {
form: Form;
showReset?: boolean;
}) {
return (
<FormActions>
<Button type="submit">
Submit
</Button>
{showReset ? (
<Button
type="button"
variant="outline"
onClick={() => {
form.reset();
}}
>
Reset
</Button>
) : null}
</FormActions>
);
}
/** Default `submit`: typing and blur stay quiet. */
export function FormValidationSubmitQuietUntilSubmitDemo() {
return (
<TimingForm<TitleValues>
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** Failed Submit paints field + summary; fixing clears without another blur. */
export function FormValidationSubmitEmptyThenClearDemo() {
return (
<TimingForm<TitleValues>
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormErrorSummary />
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** After a failed Submit, a previously valid field waits for blur. */
export function FormValidationSubmitRecoveryBlurDemo() {
return (
<TimingForm<PairValues>
initialValues={{ title: "", extra: "Warehouse A" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<FormField
name="extra"
kind="text"
label="Warehouse"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** Successful Submit does not switch the form into recovery. */
export function FormValidationSuccessfulSubmitNoRecoveryDemo() {
return (
<TimingForm<PairValues>
initialValues={{ title: "Northwind", extra: "Warehouse A" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<FormField
name="extra"
kind="text"
label="Warehouse"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** `touch`: focus-only stays quiet; leaving empty required paints. */
export function FormValidationTouchEmptyRequiredDemo() {
return (
<TimingForm<TitleValues>
revealErrors="touch"
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** `touch`: invalid value while focused does not paint until blur. */
export function FormValidationTouchInvalidWhileFocusedDemo() {
return (
<TimingForm<EmailValues>
revealErrors="touch"
initialValues={{ workEmail: "" }}
>
{(form) => (
<>
<FormField
name="workEmail"
kind="email"
label="Work email"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** Once shown, an email `onBlur` kind clears as soon as the value is valid. */
export function FormValidationTouchEmailClearsOnInputDemo() {
return (
<TimingForm<EmailValues>
revealErrors="touch"
initialValues={{ workEmail: "" }}
>
{(form) => (
<>
<FormField
name="workEmail"
kind="email"
label="Work email"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** Blur stamps that field only — a sibling stays quiet. */
export function FormValidationTouchBlurThatFieldOnlyDemo() {
return (
<TimingForm<PairValues>
revealErrors="touch"
initialValues={{ title: "", extra: "" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<FormField
name="extra"
kind="text"
label="Warehouse"
required
/>
<SubmitRow form={form} />
</>
)}
</TimingForm>
);
}
/** `always`: invalid chrome without Submit (password / count class). */
export function FormValidationAlwaysPaintsLiveDemo() {
const form = useMemo(
() => createForm<TitleValues>({ initialValues: { title: "" } }),
[],
);
useEffect(() => {
void form.validate();
}, [form]);
return (
<div className="w-full max-w-md space-y-4">
<FuiForm
form={form}
revealErrors="always"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<SubmitRow form={form} />
</FuiForm>
</div>
);
}
/** Reset clears sticky field chrome and recovery. */
export function FormValidationResetClearsChromeDemo() {
return (
<TimingForm<TitleValues>
revealErrors="touch"
initialValues={{ title: "" }}
>
{(form) => (
<>
<FormField
name="title"
kind="text"
label="Order title"
required
/>
<SubmitRow form={form} showReset />
</>
)}
</TimingForm>
);
}Submit Empty Then Clear On Fix
Failed Submit stamps every invalid field. After that, fixing the value clears chrome immediately — you do not blur again.
Try this:
- Leave Order title empty. Click Submit.
- Confirm: field error and Form Error Summary both show
{label} is required(same string). Do not ship Formily’s generic “The field value is required.” Custom validators keep their own copy. - Click the field (keep focus) and type
Northwind. - Confirm: the error and summary row disappear as soon as the title is valid — no extra blur.
Submit Recovery Waits For Blur
After a failed Submit the form switches to inline recovery: already-red fields stay sticky; a field that was valid at Submit time stays quiet until you leave it.
Try this:
- Leave Order title empty. Leave Warehouse as
Warehouse A. Click Submit. - Confirm: only Order title is red. Warehouse is still quiet.
- Click Warehouse, delete the text, keep focus (do not click away).
- Confirm: Warehouse is still quiet while you are typing it empty.
- Click outside Warehouse. Confirm: the required error appears now.
Successful Submit Does Not Open Recovery
A successful Submit must not flip the form into “show everything on the next keystroke.”
Try this:
- Both fields already have values. Click Submit. Confirm the success toast — no field errors.
- Click Warehouse, delete the text, keep focus.
- Confirm: still no error while focused.
- Click outside Warehouse. Confirm: the required error appears on blur, same as
touch.
Touch Empty Required
revealErrors="touch" first-paints on blur, including an empty required field you focused and left. Focus alone is not a commit.
Try this:
- Click Order title. Do not type. Click Submit (or any area outside the field) so the field blurs.
- Before you blur: confirm there is no error on first focus.
- After you leave the empty field: confirm the required error appears.
- Click the field again and type
Northwindwithout blurring. Confirm the error clears immediately.
Touch Invalid While Focused
Format kinds such as email validate on blur for the first show. Typing not-an-email while focused must not paint yet.
Try this:
- Click Work email. Type
not-an-email. Do not click away. - Confirm: no error yet.
- Click outside the field. Confirm: the email format error appears.
- (Clear-as-you-fix is the next demo.)
Touch Email Clears On Input
Once the email error is showing, the kit re-validates on input so a blur-kind does not trap the message until the next blur.
Try this:
- Click Work email, type
bad, click outside. Confirm the format error. - Click the field again. Replace the value with
ada@example.comwithout clicking away. - Confirm: the error disappears as soon as the address is valid.
Touch Blur Stamps That Field Only
Leaving one invalid field does not paint siblings. Each field waits for its own blur (or a later failed Submit).
Try this:
- Click Order title, do not type, click Warehouse (title blurs).
- Confirm: Order title is red; Warehouse is still quiet even though it is also empty.
- Click outside Warehouse. Confirm: Warehouse now shows its own required error.
Always Paints Without Submit
always is for live exceptions (password / character count). Empty required paints without Submit. Do not set this as the default on a long form.
Try this:
- Do not click Submit. Confirm the required error is already visible on Order title.
- Type
Northwind. Confirm it clears immediately. - Delete the title. Confirm the error returns while you are still in the field.
Reset Clears Shown Errors
Reset wipes sticky chrome and submit-recovery. The next interaction starts from a quiet form again.
Try this:
- Click Order title, click outside so the required error shows (
touch). - Click Reset.
- Confirm: the error is gone. Focus the field again without blurring — still quiet, same as a fresh form.
IME Composition
Pinyin / Hangul composition is not a first-show commit. This demo simulates composing without an IME so you can see the gate.
Try this:
- Click Work email, type
bad, click outside so the format error shows (touch). - Click Start composing, then replace the value with
ada@example.comwithout clicking End composing. Confirm the error stays (R8 skipped). - Click End composing, type the same valid address again (or add a character and delete). Confirm the error clears.
Server Field Issues Paint Immediately
Server / submit / system issues bypass the reveal gate. Do not Submit and do not blur — the mapped field paints now. Empty required siblings stay quiet.
Try this:
- Email is already valid. Warehouse is empty. Confirm: no chrome yet (
submitpolicy). - Click Apply server issue (not Submit). Confirm Email shows already registered immediately.
- Confirm Warehouse is still quiet (no fake submit-all).
- Edit Email. Confirm the server chrome clears. Submit still required-checks Warehouse.
Full map (field + unmounted path after a real Submit): Server Issues Map.
Error Summary And Focus
After a failed Submit, FormErrorSummary lists visible field issues with jump links. Toggle focusOnInvalid between summary, first-field, and false (no auto-focus).
Try this:
- Leave both fields empty. Click Submit empty.
- With
summary: confirm focus lands on the summary heading; jump links move to the field. - Switch to
first-fieldand Submit empty again: confirm the first invalid input is focused. - Switch to
false: Submit empty paints errors but does not move focus.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Focus = "summary" | "first-field" | false;
type Values = { email: string; note: string };
export function FormValidationErrorSummaryFocusDemo() {
const [focusOnInvalid, setFocus] = useState<Focus>("summary");
const form = useMemo(
() => createForm<Values>({ initialValues: { email: "", note: "" } }),
[focusOnInvalid],
);
return (
<div className="w-full max-w-md space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">focusOnInvalid</span>
<Select
value={String(focusOnInvalid)}
onValueChange={(v) =>
setFocus(v === "false" ? false : (v as Focus))
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="summary">summary</SelectItem>
<SelectItem value="first-field">first-field</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
</div>
<Form
key={String(focusOnInvalid)}
form={form}
focusOnInvalid={focusOnInvalid}
revealErrors="submit"
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormErrorSummary />
<FormField name="email" label="Email" required kind="email" />
<FormField name="note" label="Note" required />
<FormActions>
<Button type="submit">
Submit empty
</Button>
</FormActions>
</Form>
</div>
);
}Server Issues Map
Who validates: BE. Fake API returns a field issue and an unmounted-path issue. Field chrome clears when you edit; summary keeps form-level intent until the next attempt.
Try this:
- Fill Email and Username with anything. Click Submit.
- Confirm: Email shows “already registered”; the summary also lists the captcha (unmounted path) message.
- Edit Email. Confirm: the field error clears; the captcha summary row stays until the next Submit.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
const API_DELAY_MS = 400;
interface SignupValues {
email: string;
username: string;
}
/** Pretend API: rejects every submit with one field error and one form-level error. */
async function fakeSignup(): Promise<FormIssue[]> {
await new Promise((resolve) => setTimeout(resolve, API_DELAY_MS));
return [
{ source: "server", path: "email", message: "Email is already registered." },
{
source: "server",
path: "captcha",
message: "Captcha session expired — retry the submit.",
},
];
}
export function FormValidationServerIssuesMapDemo() {
const form = useMemo(
() =>
createForm<SignupValues>({
initialValues: { email: "", username: "" },
}),
[],
);
const [attempts, setAttempts] = useState(0);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="summary"
onSubmit={async () => {
const issues = await fakeSignup();
applyFormIssues(form, issues);
setAttempts((n) => n + 1);
toast.error("Server returned errors");
}}
>
<FormErrorSummary scope="all" />
<FormField
name="email"
label="Email"
required
kind="email"
description="Server rejects every email; edit the field to clear the error"
componentProps={{ placeholder: "you@example.com" }}
/>
<FormField
name="username"
label="Username"
required
component={[Input, { placeholder: "lovelace" }]}
/>
<FormActions>
<Button type="submit">
Sign up{attempts > 0 ? ` (attempt ${attempts + 1})` : ""}
</Button>
</FormActions>
</Form>
</div>
);
}Hybrid Format Then Server
Who validates: Hybrid. Client checks format + @acme.com. blocked@acme.com still fails on the server via applyFormIssues — FE preview is not authority.
Try this:
- Type
me@gmail.comand Submit. Confirm the client@acme.comerror — no server round-trip needed. - Type
ada@acme.comand Submit. Confirm success. - Type
blocked@acme.comand Submit. Confirm the server “blocked by policy” message on the field.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
type Values = { workEmail: string };
const BLOCKED = new Set(["blocked@acme.com"]);
async function fakeSave(values: Values): Promise<FormIssue[]> {
await new Promise((r) => setTimeout(r, 350));
if (BLOCKED.has(values.workEmail.toLowerCase())) {
return [
{
source: "server",
path: "workEmail",
code: "email_blocked",
message: "This address is blocked by policy. Use another work email.",
},
];
}
return [];
}
export function FormValidationHybridFormatThenServerDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { workEmail: "" } }),
[],
);
const [ok, setOk] = useState(false);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="first-field"
onSubmit={async (values) => {
const issues = await fakeSave(values);
if (issues.length) {
applyFormIssues(form, issues);
setOk(false);
toast.error("Server rejected the email");
return;
}
setOk(true);
toast.success("Saved");
}}
>
<FormErrorSummary />
<FormField
name="workEmail"
label="Work email"
required
kind="email"
description="FE checks format. Try blocked@acme.com after a valid format — server still rejects."
validator={(value?: string) => {
if (!value) return "";
const domain = value.split("@")[1]?.toLowerCase();
return domain === "acme.com"
? ""
: "Email must be an @acme.com address";
}}
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
{ok ? (
<p className="text-sm text-muted-foreground">Last save succeeded.</p>
) : null}
</div>
);
}Async Uniqueness Preview
Who validates: Hybrid (preview then server). createAsyncValidator blocks known taken names (admin / root / system) on input. A name the FE thinks is free can still fail on submit — try reserved, which maps a server issue via applyFormIssues. Prefer debounce so the network is not hammered.
Try this:
- Type
adminand wait ~400ms. Confirm “already taken” from the FE preview. - Change to
ada. Confirm the preview error clears. - Type
reservedand Submit. Confirm the server issue — FE thought it was free.
"use client";
import type { FieldValidator } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createAsyncValidator } from "@/components/f-ui/formily/internals/create-async-validator";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
type Values = { username: string };
/** FE async preview — known taken names. */
const FE_TAKEN = new Set(["admin", "root", "system"]);
/** BE also rejects this name even when FE preview says free. */
const BE_RESERVED = "reserved";
const checkUsername = createAsyncValidator(
async (value, ctx) => {
await new Promise((r) => setTimeout(r, 400));
if (ctx.signal.aborted) return "";
const username = typeof value === "string" ? value : "";
if (!username) return "";
return FE_TAKEN.has(username.toLowerCase())
? "Username is already taken"
: "";
},
{ debounceMs: 300 },
);
async function fakeReserve(values: Values): Promise<FormIssue[]> {
await new Promise((r) => setTimeout(r, 350));
if (values.username.toLowerCase() === BE_RESERVED) {
return [
{
source: "server",
path: "username",
code: "username_reserved",
message: "This username is reserved. Choose another.",
},
];
}
return [];
}
export function FormValidationAsyncUniquenessDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { username: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
focusOnInvalid="first-field"
onSubmit={async (values) => {
const issues = await fakeReserve(values);
if (issues.length) {
applyFormIssues(form, issues);
toast.error("Server rejected the username");
return;
}
toast.success(`Reserved @${values.username}`);
}}
>
<FormErrorSummary />
<FormField
name="username"
label="Username"
required
validatingDescription="Checking availability…"
description="Try admin (async blocks). Try reserved (preview free, submit maps server issue)."
validator={
{
triggerType: "onInput",
validator: checkUsername,
} as FieldValidator
}
/>
<FormActions>
<Button type="submit">
Reserve
</Button>
</FormActions>
</Form>
</div>
);
}Required On Submit
Same default as Submit Empty Then Clear On Fix, with the prop omitted (revealErrors default "submit").
Try this:
- Type
xin Order title. Confirm: no error yet. - Click Submit while empty (clear the field first). Confirm the required error.
- Type a title without blurring. Confirm it clears.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
type Values = { orderTitle: string };
export function FormValidationRequiredSubmitRevealDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { orderTitle: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Submitted");
}}
>
<FormField
name="orderTitle"
kind="text"
label="Order title"
required
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Kind Plus Validator
kind="email" supplies format first; an appended custom validator then requires @acme.com. Try a well-formed personal address vs a work address.
Try this:
- In Personal email, type
not-an-email, blur. Confirm the built-in format error. - In Work email, type
me@gmail.com, Submit. Confirm@acme.comis required. - Type
ada@acme.comin Work email and Submit. Confirm success.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
interface InviteValues {
personalEmail: string;
workEmail: string;
}
function acmeDomainValidator(value: string | undefined) {
if (!value) return "";
const domain = value.split("@")[1]?.toLowerCase();
return domain === "acme.com"
? ""
: "Email must be an @acme.com address";
}
export function FormValidationKindPlusValidatorDemo() {
const form = useMemo(
() =>
createForm<InviteValues>({
initialValues: { personalEmail: "", workEmail: "" },
}),
[],
);
const [submitted, setSubmitted] = useState<InviteValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form form={form} onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}>
<FormField
name="personalEmail"
label="Personal email"
kind="email"
componentProps={{ placeholder: "you@example.com" }}
description="kind only — built-in format: email on blur"
/>
<FormField
name="workEmail"
label="Work email"
required
kind="email"
componentProps={{ placeholder: "you@acme.com" }}
description="Format first, then @acme.com — try me@gmail.com vs ada@acme.com"
validator={acmeDomainValidator}
/>
<FormActions>
<Button type="submit">
Send invite
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Built-In Rules
Formily rule objects on validator: minLength / maxLength, pattern, and minimum / maximum for numbers. Submit with short codes, bad SKUs, or out-of-range qty.
Try this:
- Type
ABin Code and Submit. Confirm “Use 3–8 characters”. - Type
nopein SKU and Submit. Confirm “Pattern AA-000” (useAB-001). - Set Qty to
0or100and Submit. Confirm “1–99”.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
type Values = { code: string; qty: number | null; sku: string };
export function FormValidationBuiltinRulesDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: { code: "", qty: null, sku: "" },
}),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("OK");
}}
>
<FormField
name="code"
label="Code"
required
validator={{
minLength: 3,
maxLength: 8,
message: "Use 3–8 characters",
}}
description="minLength / maxLength"
/>
<FormField
name="sku"
label="SKU"
required
validator={{
pattern: /^[A-Z]{2}-\d{3}$/,
message: "Pattern AA-000",
}}
/>
<FormField
name="qty"
label="Qty"
kind="number"
required
validator={{ minimum: 1, maximum: 99, message: "1–99" }}
componentProps={{ min: 1, step: 1 }}
/>
<FormActions>
<Button type="submit">
Validate
</Button>
</FormActions>
</Form>
</div>
);
}Checkbox Must Be True
Formily required alone does not treat false as empty on booleans. Pair kind="checkbox" with a truthy validator (and inlineLabel for the label beside the control).
Try this:
- Leave the checkbox unchecked. Click Continue. Confirm “You must agree”.
- Check it. Confirm the error clears (or Submit succeeds).
- Do not rely on
requiredalone for a boolean — that is the kit gotcha this recipe exists for.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
type Values = { accept: boolean };
/** Formily `required` does not treat `false` as empty on boolean fields. */
function mustAgree(value: boolean | undefined) {
return value ? "" : "You must agree";
}
export function FormValidationCheckboxMustBeTrueDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { accept: false } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Accepted");
}}
>
<FormField
name="accept"
label="I agree to the terms"
required
kind="checkbox"
inlineLabel
validator={mustAgree}
/>
<FormActions>
<Button type="submit">
Continue
</Button>
</FormActions>
</Form>
</div>
);
}Mixed / Approver Lock
Approver inbox (Unlock exists, not yet unlocked) is Display: applicant answers use readPretty, same as request number and created time. Comment stays editable. Unlock remounts applicant fields as controls (Fiori Display → Edit, Salesforce view → pencil). Do not paint that inbox as a wall of readOnly inputs.
An Edit session can still mix three chromes on one surface: Created stays readPretty (never a control). A field this role still cannot change stays a readOnly control. Comment stays editable. Empty pretty values use an em dash; empty locked and editable controls stay blank — Empty Value Placeholder.
A Display page with no Unlock uses form-level readPretty. Full pair: Form (Formily) — When To Use. Lock mark placement and field vs surface scope: Field Lock Affordance. Showcase: /showcases/approver-unlock.
Try this (Edit mixed, demo below):
- Confirm Created is a date string — not a picker. Clearing it in code would show —, not an empty input.
- Tab to SKU. Confirm it is a textbox you cannot type into (not grey-disabled). This is leftover lock in Edit, not the approver inbox.
- Type in Comment. Confirm that is the only writable field. Submit.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Textarea } from "@/components/f-ui/formily/connects/textarea";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
interface ReviewValues {
createdAt: Date | null;
sku: string;
region: string;
comment: string;
}
export function FormilyMixedPatternDemo() {
const form = useMemo(
() =>
createForm<ReviewValues>({
initialValues: {
createdAt: new Date(2026, 7, 1),
sku: "SKU-104",
region: "apac",
comment: "",
},
}),
[],
);
const [submitted, setSubmitted] = useState<ReviewValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="createdAt"
label="Created"
description="System timestamp — display only"
readPretty
kind="date"
/>
<FormField
name="sku"
label="SKU"
description="Locked in this Edit session — still a textbox. Approver inbox uses display until Unlock, not this chrome."
readOnly
lockReason="Locked for this Edit session."
component={[Input]}
/>
<FormField
name="region"
label="Region"
readOnly
kind="radio"
lockReason="Set by the approval route — reopen the request to change it."
componentProps={{
options: [
{ label: "APAC", value: "apac" },
{ label: "EMEA", value: "emea" },
],
orientation: "horizontal",
}}
/>
<FormField
name="comment"
label="Comment"
description="Writable this session"
component={[Textarea, { placeholder: "Approver note" }]}
/>
<FormActions>
<Button type="submit">
Submit review
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Cross-Field Local
Confirm password uses reactions + field.query(".password") for a local match error. Phone becomes required and visible only when Notify by is SMS — same reaction pattern as Formily demos. Keep local match rules on the client; server aggregates (totals, inventory, concurrency) still return path or root issues via applyFormIssues.
Try this:
- Type
secret1in Password andsecret2in Confirm password. Confirm “Passwords must match”. - Make Confirm match Password. Confirm the match error clears.
- Set Notify by to SMS. Confirm Phone appears and is required. Submit empty phone to see that error. Switch back to Email: Phone hides.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Input } from "@/components/f-ui/formily/connects/input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
type Values = {
password: string;
confirm: string;
notifyBy: "email" | "sms";
phone?: string;
};
const NOTIFY_OPTIONS = [
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
];
export function FormValidationCrossFieldLocalDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: {
password: "",
confirm: "",
notifyBy: "email",
phone: "",
},
}),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Account updated");
}}
>
<FormField
name="password"
label="Password"
required
component={[
Input,
{ type: "password", autoComplete: "new-password" },
]}
/>
<FormField
name="confirm"
label="Confirm password"
required
description="Matches password via reactions + field.query"
component={[
Input,
{ type: "password", autoComplete: "new-password" },
]}
reactions={(field) => {
const password = field.query(".password").value() as
| string
| undefined;
field.selfErrors =
field.value && field.value !== password
? ["Passwords must match"]
: [];
}}
/>
<FormField
name="notifyBy"
label="Notify by"
required
component={[Select, { options: NOTIFY_OPTIONS }]}
/>
<FormField
name="phone"
label="Phone"
description="Required only when Notify by is SMS"
component={[Input, { placeholder: "+1 555 0100" }]}
reactions={(field) => {
const sms = field.query(".notifyBy").value() === "sms";
field.visible = sms;
field.required = sms;
}}
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
</div>
);
}Warning Severity
Who validates: Hybrid (server severity). applyFormIssues with severity: "warning" stays on the field (and Message Popover when the host builds that list). It does not appear in Form Error Summary. This demo still succeeds when only warnings are present; toggle to inject an error and block.
Try this:
- Leave the select on
warning-only. Submit. Confirm a warning on Memo, an empty Banner, and the success toast — warnings do not block. - Switch to
warning-and-error. Submit. Confirm the Banner lists the invoice error only (not the Memo warning) and success does not fire.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormErrorSummary } from "@/components/f-ui/formily/form-error-summary";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import type { FormIssue } from "@/components/f-ui/formily/internals/form-issue";
import { applyFormIssues } from "@/components/f-ui/formily/internals/form-issues";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type Values = { invoiceNo: string; memo: string };
type InjectMode = "warning-only" | "warning-and-error";
/**
* Host policy: warnings do not block success. They stay on the field — not
* in Form Error Summary. Mode select injects an error so the Banner lists
* the blocking issue only.
*/
export function FormValidationWarningSeverityDemo() {
const form = useMemo(
() =>
createForm<Values>({
initialValues: { invoiceNo: "INV-1001", memo: "" },
}),
[],
);
const [injectMode, setInjectMode] = useState<InjectMode>("warning-only");
const [lastOk, setLastOk] = useState(false);
return (
<div className="w-full max-w-sm space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">on submit</span>
<Select
value={injectMode}
onValueChange={(v) => setInjectMode(v as InjectMode)}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warning-only">warning only</SelectItem>
<SelectItem value="warning-and-error">
warning + field error
</SelectItem>
</SelectContent>
</Select>
</div>
<Form
form={form}
focusOnInvalid="summary"
onSubmit={() => {
const issues: FormIssue[] = [
{
source: "server",
path: "memo",
severity: "warning",
message:
"Memo is empty — invoice will use the default text.",
},
];
if (injectMode === "warning-and-error") {
issues.push({
source: "server",
path: "invoiceNo",
severity: "error",
message: "Invoice number is reserved. Pick another.",
});
applyFormIssues(form, issues);
setLastOk(false);
toast.error("Submit blocked by an error");
return;
}
applyFormIssues(form, issues);
setLastOk(true);
toast.success("Saved (warning only — host allowed submit)");
}}
>
<FormErrorSummary scope="all" />
<FormField
name="invoiceNo"
label="Invoice number"
required
description="Warnings stay on the field; this demo still succeeds when only warnings are present"
/>
<FormField
name="memo"
label="Memo"
description="Leave empty to get a warning after Save"
/>
<FormActions>
<Button type="submit">
Save
</Button>
</FormActions>
</Form>
{lastOk ? (
<p className="text-sm text-muted-foreground">
Last save succeeded. Memo still shows a field warning; the Banner stays empty.
</p>
) : null}
</div>
);
}Hide Helper On Error
Long description plus hideHelperOnError: after Submit on an empty required field, the helper is visually hidden while the error shows (still available to assistive tech).
Try this:
- Read the long helper under Account code.
- Click Submit with the field empty.
- Confirm: the helper is visually gone; the required error is visible instead.
"use client";
import { useMemo } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
type Values = { accountCode: string };
export function FormValidationHideHelperOnErrorDemo() {
const form = useMemo(
() => createForm<Values>({ initialValues: { accountCode: "" } }),
[],
);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={() => {
toast.success("Saved");
}}
>
<FormField
name="accountCode"
kind="text"
label="Account code"
required
hideHelperOnError
description="Use the 6-character ledger code from Finance (e.g. ACC001). This long helper is hidden visually when the required error shows, but stays in the accessibility tree."
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
</div>
);
}Steps Form Hybrid
Per-step fields validate on Next (FE). The write API runs on the last Submit only.
Try this:
- Leave Work email empty. Click Next. Confirm the step error — finish must not run.
- Fill a valid email, go through Access, Submit on Message. Confirm the success toast.
"use client";
import { toast } from "sonner";
import { FormField } from "@/components/f-ui/formily/form-field";
import { StepsForm } from "@/components/f-ui/steps-form/steps-form";
interface InviteWizardValues {
email: string;
role: string;
message: string;
}
/**
* Leave the first step empty and click Next — required errors stay on Basics.
* Fill email, advance, then Back: the email is still there.
*/
export function StepsFormValidationDemo() {
return (
<div className="w-full max-w-lg">
<StepsForm<InviteWizardValues>
initialValues={{ email: "", role: "member", message: "" }}
onFinish={async (values) => {
await new Promise((r) => setTimeout(r, 300));
toast.success(`Invite queued for ${values.email}`);
}}
>
<StepsForm.Step name="basics" title="Basics">
<FormField
kind="email"
name="email"
label="Work email"
required
componentProps={{ placeholder: "name@company.com" }}
/>
</StepsForm.Step>
<StepsForm.Step name="access" title="Access">
<FormField
kind="select"
name="role"
label="Role"
required
componentProps={{
options: [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
],
}}
/>
</StepsForm.Step>
<StepsForm.Step name="message" title="Message">
<FormField
kind="textarea"
name="message"
label="Personal note"
componentProps={{
rows: 3,
placeholder: "Optional note in the invite email",
}}
/>
</StepsForm.Step>
</StepsForm>
</div>
);
}Query Filter Search Submit
Search runs form.submit() with submit reveal — typing alone does not paint.
Try this:
- Type
ain Keyword (min length when filled) ornot-an-emailin Email. Click Search. - Confirm chrome after Search, not while typing. Fix and Search again.
Click Search with keyword a or email not-an-email — validation blocks onFinish. Fix the fields, then Search again.
Search attempts: 0 — only increments when validation passes and onFinish runs.
No successful query yet.
"use client";
import { useState } from "react";
import { QueryFilter } from "@/components/f-ui/query-filter/query-filter";
import { FormField } from "@/components/f-ui/formily/form-field";
function minLengthWhenFilled(min: number, message: string) {
return (value: string | undefined) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "";
return trimmed.length >= min ? "" : message;
};
}
export function QueryFilterValidationDemo() {
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
const [attempts, setAttempts] = useState(0);
return (
<div className="w-full space-y-4">
<p className="text-muted-foreground text-xs">
Click <strong>Search</strong> with keyword <code>a</code> or email{" "}
<code>not-an-email</code> — validation blocks <code>onFinish</code>. Fix the
fields, then Search again.
</p>
<QueryFilter
onFinish={(values) => {
setSubmitted(values);
setAttempts((n) => n + 1);
}}
onReset={() => {
setSubmitted(null);
setAttempts(0);
}}
>
<FormField
kind="text"
name="keyword"
label="Keyword"
description="Optional — at least 2 characters when filled"
validator={minLengthWhenFilled(2, "Enter at least 2 characters")}
/>
<FormField
kind="email"
name="customerEmail"
label="Customer email"
description="Optional — format checked on blur"
componentProps={{ placeholder: "buyer@example.com" }}
/>
<FormField
kind="currency"
name="minAmount"
label="Min amount"
description="Optional — must be zero or positive"
componentProps={{ currency: "USD", placeholder: "0.00" }}
validator={(value) => {
if (value === undefined || value === null || value === "") return "";
return Number(value) >= 0 ? "" : "Amount cannot be negative";
}}
/>
</QueryFilter>
<div className="text-muted-foreground space-y-2 text-xs">
<p>
Search attempts: <strong>{attempts}</strong> — only increments when validation
passes and <code>onFinish</code> runs.
</p>
{submitted ? (
<pre className="bg-muted overflow-auto rounded-md p-3">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : (
<p>No successful query yet.</p>
)}
</div>
</div>
);
}Editable Table Cells
Do not paint cell errors with applyFormIssues. Use identity-keyed applyEditableArrayIssues, cell chrome, and the region rollup. Array-root rules stay on Form Error Summary. Composition with header fields and large-grid extremes: Where Errors Appear.
Try this:
- Set a line Disc % above 0, leave Disc reason empty, blur, then Submit.
- Focus the invalid reason cell before fixing. Confirm hide-while-focused vs FormItem sticky red.
- Confirm the rollup lists the cell and Form Error Summary does not duplicate that cell path.
"use client";
import { isField } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";
type Line = {
id: string;
sku: string;
discountPct: number;
discountReason: string;
};
const columns = defineEditableColumns<Line>({
sku: {
...f.text({ label: "SKU" }),
size: 120,
field: { component: [Input], readOnly: () => true },
},
discountPct: {
...f.number({
label: "Disc %",
}),
size: 90,
field: {
component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
validator: (v) => {
const n = Number(v);
if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
return undefined;
},
},
},
discountReason: {
...f.text({
label: "Disc reason",
}),
size: 180,
field: {
component: [Input, { placeholder: "Reason for discount" }],
reactions: (field) => {
const lines = field.form.values.lines as Line[] | undefined;
const row = lines?.[field.index as number];
const discounted = Number(row?.discountPct) > 0;
field.display = discounted ? "visible" : "none";
if (isField(field)) field.required = discounted;
},
validator: (value, row) => {
const line = row as Line | undefined;
if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
return "Required when discount > 0";
}
return undefined;
},
},
},
});
export function EditableTableCellValidationDemo() {
const form = useMemo(
() =>
createForm<{ lines: Line[] }>({
initialValues: {
lines: [
{ id: "l1", sku: "SKU-001", discountPct: 0, discountReason: "" },
{ id: "l2", sku: "SKU-002", discountPct: 0, discountReason: "" },
],
},
}),
[],
);
return (
<Form
form={form}
onSubmit={async () => {
toast.success("Submitted");
}}
>
<EditableTable<Line>
name="lines"
columns={columns}
getRowId={(r) => r.id}
editMode="inline"
features={{ footer: false, rowActions: false }}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
);
}File Upload Required
Pair required with validateFileRequired. Uploading items must not look like “done.” Content safety is BE-only.
Try this:
- Click Submit with no file. Confirm required chrome.
- Add a file and wait until it is done, then Submit. Confirm success.
- Do not treat an in-flight upload as a valid value.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { FileUploadItem } from "@/components/f-ui/file-upload/file-upload-types";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { validateFileRequired } from "@/components/f-ui/formily/validators/file-upload";
import { Button } from "@/components/ui/button";
import { createFakeUpload } from "./fake-upload";
interface AttachmentValues {
attachments: FileUploadItem[];
}
const upload = createFakeUpload();
export function FileUploadFormFieldDemo() {
const form = useMemo(
() =>
createForm<AttachmentValues>({
initialValues: { attachments: [] },
}),
[],
);
const [submitted, setSubmitted] = useState<AttachmentValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form
form={form}
onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}
>
<FormField
name="attachments"
label="Attachments"
description="Required — upload at least one file. Progress is not an error; submit waits until uploads finish."
kind="file"
required
validator={[{ validator: validateFileRequired }]}
componentProps={{
multiple: true,
upload,
}}
/>
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Form Page And Modal Defaults
Long pages: FormPage default is scope="all" + focusOnInvalid="summary" + a docked Submit. Dialogs: Modal With A Table. Focus toggle: Error Summary And Focus.
Try this:
- The demo already submitted empty. Confirm the summary lists Order title / Warehouse, not the cell SKU.
- Confirm the docked footer Submit is visible (Form Page chrome).
- Fill the headers. Confirm those summary rows drop; the table rollup remains.
Schema Validators
JSON Schema forms use format + x-validator the same way FormField stacks kind + validator. Timing still follows this page’s revealErrors policy.
Try this:
- Leave Email empty or type
not-an-email, Submit. Confirm format/required chrome. - Switch Role to Admin. Confirm Admin code appears and is required.
"use client";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { SchemaField } from "@/components/f-ui/formily/schema-field";
import { Button } from "@/components/ui/button";
const ROLE_OPTIONS = [
{ label: "Member", value: "member" },
{ label: "Admin", value: "admin" },
];
const schema = {
type: "object",
properties: {
email: {
type: "string",
title: "Email",
required: true,
format: "email",
"x-decorator": "FormItem",
"x-component": "EmailInput",
"x-component-props": { placeholder: "you@example.com" },
},
role: {
type: "string",
title: "Role",
"x-decorator": "FormItem",
"x-component": "Select",
"x-component-props": { options: ROLE_OPTIONS },
},
adminCode: {
type: "string",
title: "Admin code",
description: "Visible and required only for admins",
"x-decorator": "FormItem",
"x-component": "Input",
"x-component-props": { placeholder: "1234", autoComplete: "off" },
"x-reactions": {
dependencies: ["role"],
fulfill: {
state: {
visible: '{{$deps[0] === "admin"}}',
required: '{{$deps[0] === "admin"}}',
},
},
},
},
},
};
interface SchemaValues {
email: string;
role: string;
adminCode?: string;
}
export function FormilySchemaDemo() {
const form = useMemo(
() =>
createForm<SchemaValues>({
initialValues: { email: "", role: "member" },
}),
[],
);
const [submitted, setSubmitted] = useState<SchemaValues | null>(null);
return (
<div className="w-full max-w-sm space-y-4">
<Form form={form} onSubmit={(values) => {
setSubmitted({ ...values });
toast.success("Submitted successfully");
}}>
<SchemaField schema={schema} />
<FormActions>
<Button type="submit">
Submit
</Button>
</FormActions>
</Form>
{submitted ? (
<pre className="bg-muted text-muted-foreground overflow-auto rounded-md p-3 text-xs">
<code>{JSON.stringify(submitted, null, 2)}</code>
</pre>
) : null}
</div>
);
}Security Sensitive Failures
Login / OTP: one root issue. Do not say which of email or password was wrong.
Try this:
- Type any email and password. Click Sign in.
- Confirm the summary shows Email or password is incorrect.
- Confirm neither field is singled out as “wrong password” / “unknown user.”
I18n And Issue Codes
Built-in format / required copy follows the docs site language (en vs /zh/docs/...). Custom and server copy: keep a stable code for logic; show a localized message. Toggle below only swaps the custom Account code strings.
Try this:
- Confirm Email’s built-in required/format string matches the site language.
- Flip the switch. Confirm Account code copy switches en ↔ zh. Email built-in does not follow this switch.
- Type
!!!in Account code. Confirm the pattern message in the selected custom language.
4. Shell Defaults
| Shell | Cookbook action |
|---|---|
| Form (Formily) | Baseline for all independent demos above |
| Form Page | Long page; default focusOnInvalid="summary" + FormErrorSummary; see Form Page And Modal Defaults |
| Modal Form | Dialog lifecycle; revealErrors / focusOnInvalid (default first-field) |
| Steps Form / Modal Steps Form | Per-step FE; write API on last Submit — Steps Form Hybrid |
| Query Filter | Search runs form.submit() — Query Filter Search Submit |
| Editable Table | Cells + rollup via applyEditableArrayIssues — Editable Table Cells |
| Inline Edit | Single-field save loop; not a primary cookbook recipe — use Formily for full fill forms |
5. Anti-Patterns
| Forbidden | Do instead |
|---|---|
| Summary-only field errors | Summary and field chrome |
| Dual-list table cell errors | One aggregate owner (rollup or popover) |
applyFormIssues under Editable Table cells | applyEditableArrayIssues |
| FE as security boundary | Always re-validate on the server |
| Enumerating users via field errors | Generic root message |
revealErrors="always" as the long-form default | "submit" (or "touch" when justified) |
| Hand-rolled mini forms | Formily |
editable={false} as a session lock | readOnly / readPretty / form readOnly — Formily maps editable={false} to pretty |
Treating server message as a stable API | Prefer code for logic; show message |
| Calling the write API after client validation failed | Gate on successful submit |
6. FAQ
What is the default reveal policy? revealErrors="submit" — field errors after Submit (omit the prop). Use "touch" when blur feedback is justified; avoid "always" on long forms. touch means blur-then-show, not “first keystroke.” Hosts that need live-as-you-type on first entry should use always. Walk every case in When Errors Show.
Is client validation enough? No — UX preview only. The server remains authoritative for business invariants.
When do I use async validators? For previews such as username taken (createAsyncValidator); still re-check on submit (hybrid: preview then server). Debounce so the network is not hammered.
Can I list Editable Table cell errors in Form Error Summary and the table rollup? No — pick one aggregate owner. Cell paths use applyEditableArrayIssues + rollup; array-root / header stay on the summary. Decision tree: Where Errors Appear. See Object Messaging.
When do I turn Form Error Summary off? When the only errors are table cells (no header, array-root, or system issues) — errorSummary={false} and let the rollup own the list. Keep the Form Page default on when the page also has header or “at least one line” rules; the kit strips cell paths for you.
How do I show errors on a huge form? Header: Form Error Summary is the index (jump links, focus summary first). Lines: region rollup, never flatten 50 cell messages into the page summary. Hidden rows still fail Submit — Extreme Cases.
Warning vs error — does a warning block Submit? severity: "warning" does not appear in Form Error Summary. It does not block host success by itself. Inject an error when the submit must fail.
May I hand-roll a tiny Input + Button form? No — every fill-and-submit surface uses Formily (Form / FormField / shells). Read-only confirmations and non-field actions are exempt.
How do I stop double Submit? FormActions / Form Page disable the primary while submitting. Idempotency and “already processed” responses are host / backend concerns — do not rely on the button alone.
Where is table validation documented? Editable Table + Object Messaging.
Current Limitations
- Async validator typing — when wiring
createAsyncValidatoras{ triggerType, validator }, TypeScript may require anas FieldValidatorcast. Debounce and abort still work at runtime; cast only the prop until the helper’s return type matches Formily.
Form Layout
Choose vertical fields, two-column field grids, control widths, and FormActions placement — without confusing “two columns” with Ant labelCol.
Field Lock Affordance
How locked fill-in fields show a lock mark and optional reason across forms, Editable Table, and Inline Edit — without looking like system facts or security.