Form Page
Formily page shell with draft persistence, unsaved-change navigation guards, and status-aware footer actions.
Plus Registry
Form Page ships on the authenticated @f-ui-plus registry. Configure FUI_PLUS_REGISTRY_TOKEN as described in Installation — Plus Registry.
Form Page wraps a Formily form in a page-level shell that handles submit/cancel actions, draft persistence, and navigation guards. Use it for create/edit pages where users may leave mid-edit and need clear draft/save state feedback.
For which page type to use (list vs detail vs create vs edit, and when to avoid Form Page), see CRUD Page Patterns. For reveal modes, hybrid authority, and copy-paste validation recipes, see Form Validation.
When To Use
- You need a page-level form shell with consistent submit/cancel footer actions.
- Users should be warned before leaving with unsaved changes.
- Draft persistence should run independently from final submit validation.
- The form includes embedded sections such as
EditableTableline items. - You need page defaults for
focusOnInvalid="summary"and Form Error Summary — see Form Validation.
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/form-page.jsonnpx shadcn@latest add https://ui.isaacfei.com/api/plus/r/form-page.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/form-page.jsonbunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/form-page.jsonWith the @f-ui-plus namespace configured in components.json, shadcn add @f-ui-plus/form-page also works.
Usage
import { FormPage } from "@/components/f-ui/form-page/form-page";
<FormPage form={form} mode="create" onFinish={handleSubmit}>
{/* sections */}
</FormPage>;Persistence
persistDraft receives current form values and can store incomplete work without running full submit validation. Enable background autosave with autoSave.debounceMs.
<FormPage
form={form}
mode="create"
onFinish={handleSubmit}
persistence={{
persistDraft: async (values) => api.saveDraft(values), // may return the draft id
autoSave: {
debounceMs: 2000,
// Create: the first successful save assigns an id — upgrade the route.
onDraftCreated: (id) => router.replace(`/orders/${id}/edit`),
},
// Sensitive fields never enter the draft payload or dirty detection.
excludeFields: ["payment.cardNumber"],
}}
/>Draft id on create. When persistDraft resolves with a string (or { id }), Form Page adopts it: on mode="create" it writes the id back into form.values.id (so later saves patch the same draft) and calls autoSave.onDraftCreated(id) exactly once. Use that callback to upgrade the URL from /orders/new to /orders/:id/edit.
Excluding sensitive fields. excludeFields takes dot paths (e.g. ["payment.cardNumber"]). Listed fields are stripped from the persistDraft payload and ignored by dirty / auto-save detection — editing one never schedules a save. Use it for financial, security, or privacy fields that must not be auto-saved to a draft. See When To Enable Auto-Save for the full decision checklist.
Excluded fields have only one commit path: Submit (form.submit(onFinish) reads form.values directly, so the field is included). They are never written by auto-save or Save draft. Because losing typed sensitive input on an accidental navigation is still bad, the unsaved-changes guard does fire when an excluded field has in-session edits — but it offers only Leave / Stay, not Save draft & leave (a draft save cannot capture the field). So a user who types a card number and tries to leave is warned; if they proceed, that value is intentionally dropped and must be re-entered. This is the standard payment-form trade-off (see When To Enable Auto-Save).
Draft Preconditions (draftValidate)
Drafts save partial, unvalidated work — that is the whole point, and full validation stays on Submit. But some entities cannot be stored at all until a minimal set of fields is present (e.g. the tenant/workspace or title needed to create the draft row). draftValidate names those field paths; each is checked against its own existing validators (no rules are duplicated between draft and submit):
persistence={{
persistDraft,
draftValidate: "workspaceId", // or ["workspaceId", "title"]
}}| Trigger | Gate fails |
|---|---|
| Auto-save | Silently deferred — stays Unsaved, no error surfaced, no field turns red. The next edit re-checks; once the gate passes, auto-save resumes. |
| Manual Save draft | Only the gate fields reveal their errors; the draft is not persisted. Save draft & leave in the navigation guard also stays on the page (the save promise rejects; the dialog re-enables without navigating). |
Keep draftValidate to the true storability minimum, not the full required set — otherwise a draft that saves fine but fails Submit later confuses users. For rules that live server-side, throw from persistDraft: that surfaces as the Failed status with retry. This mirrors the industry split of background/precondition checks (non-blocking) vs authoritative submit validation (blocking).
Loading
Edit pages usually hydrate from an async record. Prefer building the form after the fetch resolves — createForm({ initialValues: record }) — and mounting FormPage only when data is ready. When the form must exist before the record arrives, pass loading:
<FormPage form={form} mode="edit" loading={query.isPending} onFinish={handleSubmit}>
{/* sections */}
</FormPage>While loading is true, Form Page renders a field skeleton and suppresses the footer and the unsaved-changes guard. Flip it to false after you have applied the record (form.setInitialValues(record) / form.setValues(record)) so the persistence baseline and guard arm against the loaded values — never against an empty-then-hydrated form.
Footer Modes
Form Page supports two footer behaviors:
- Status-aware auto-save mode: set
persistence.autoSaveand use status text to communicate draft save progress/timestamps. - Manual action mode: omit autosave and keep explicit footer controls (
submitter.submitText, cancel action, optional custom draft actions).
When persistence is configured, the footer stays docked (status + Submit) even on a clean form — matching Ant Design Pro full-page forms.
Draft save vs Submit
| Action | Validation | Mechanism |
|---|---|---|
| Save / auto-save | No | persistDraft(form.values) — reads current values as-is |
| Submit (Create / Save primary button) | Yes | form.submit(onFinish) — full Formily validation |
Use submitter.showManualSaveWhenAutoSave: true if you want an explicit Save draft button alongside silent auto-save.
Persistence lifecycle
Dirty / auto-save state is tracked against a single persisted baseline (a stable snapshot of the values last known to be saved). There is one owner of "these values are saved now", and it advances at exactly these moments:
| Moment | What happens to the baseline |
|---|---|
Mount (create), or loading → false (edit) | Baseline captured from current values; status starts idle. |
| Each edit | Values differ from the baseline → status Unsaved; auto-save is scheduled (autoSave.debounceMs). |
| Successful draft save (auto or manual) | Baseline advances to the just-saved values; status Saved. |
Successful Submit (onFinish resolves) | Baseline advances to the submitted values and status returns to idle — the leave guard releases and auto-save re-arms on the next edit. |
| Reset | Submit attempts, revealed fields, and alerts clear; the baseline realigns to the reset values. |
The post-Submit re-baseline is what keeps auto-save alive across a create-then-keep-editing flow: without it, values committed by Submit would still look "unsaved" and the next edit would compare against a stale baseline. Programmatic writes made while committing a baseline (id write-back, setInitialValues) are suppressed so they never re-trigger auto-save.
Warn On Unsaved Changes
FormPage is router-agnostic: it registers the native tab-close prompt and broadcasts guard
state, but imports no router. Add the matching adapter guard as a child of FormPage.
TanStack Router
Add the form-page-guard-tanstack registry item, then drop <FormPageUnsavedGuard/> inside FormPage:
import { FormPageUnsavedGuard } from "@/components/f-ui/form-page/navigation-guard/tanstack-router";
<FormPage form={form} onFinish={handleSubmit} warnOnUnsavedChanges>
<FormPageUnsavedGuard />
{/* fields */}
</FormPage>;Next.js App Router
Add the form-page-guard-next registry item. Wrap the app in the provider, drop the guard inside
FormPage, and navigate with GuardedLink / useGuardedRouter (both preserve SPA navigation).
import {
FormNavigationGuardProvider,
FormPageUnsavedGuard,
GuardedLink,
useGuardedRouter,
} from "@/components/f-ui/form-page/navigation-guard/next-app-router";
// app/layout.tsx
<FormNavigationGuardProvider>{children}</FormNavigationGuardProvider>;
// form page
<FormPage form={form} onFinish={handleSubmit} warnOnUnsavedChanges>
<FormPageUnsavedGuard />
{/* fields */}
</FormPage>;
// navigate with the guarded primitives instead of next/link + useRouter
function Actions() {
const router = useGuardedRouter();
return (
<>
<GuardedLink href="/orders">Back to orders</GuardedLink>
<button type="button" onClick={() => router.push("/orders")}>
Cancel
</button>
</>
);
}Embedded Editable Table
Invalid submit focuses the page FormErrorSummary by default (focusOnInvalid="summary"). Pass { status: "error", issues } from onFinish for known server/business failures — same FormSubmitResult contract as Formily.
When line items are part of the same page submit, render EditableTable with variant="embedded" so table spacing and panel rhythm match FormPage sections.
<EditableTable
name="lineItems"
variant="embedded"
editMode="inline"
columns={columns}
getRowId={(row) => row.id}
/>FormPage mounts FormErrorSummary scope="all" when the summary is enabled (errorSummary is only opt-out via false — no scope prop on FormPage). After Editable Table registers as cell-error owner, that summary drops owned cell paths (lineItems.*) and still lists header fields plus array-root (lineItems, e.g. “at least one line”). Prefer that default on table-heavy pages that also have header or array-root rules.
When errors are only table cells (no header / array-root / system issues), turn the summary off so the rollup owns the aggregate:
<FormPage errorSummary={false} onFinish={...}>
{/* header fields + EditableTable */}
</FormPage>Or compose a root/system-only banner yourself (scope="form" filters to issue.path == null only — it does not include header or array-root paths):
<FormPage errorSummary={false} onFinish={...}>
<FormErrorSummary scope="form" />
{/* EditableTable */}
</FormPage>