Record form
Lightning's New and Edit record form, with required checks and save errors. The app does the saving.
Installation
The CLI installs the Radix or Base UI file to match your project, with what it builds on: alert, button, checkbox, date-picker, datetime-picker, dialog, field, icon, input, input-group, lookup, number-input, record-detail, select, spinner, textarea, time-picker, tooltip.
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- To create or edit a Salesforce record: Lightning's New and Edit forms, what
lightning-record-edit-formandlightning-record-formdraw. - When the fields, which are required and the picklist values come from the object's metadata and page layout, and the app saves through
createRecord,updateRecordor the REST API. - In a New Record modal, or on a page.
- When one field needs a control the form doesn't draw, like a multi-select picklist: write it with
useRecordFormControland keep the form's checks, undo and focus.
Don't use it
- To show a record's values without editing them. Use Record detail, or Data table for many records.
- To edit a record's Details tab in place, with a pencil on each field. Use Record inline edit.
- For a form that isn't a record's, like a search or a settings panel. Compose Field with its controls.
- For one field on its own. Use Field with the control, like Lookup or Date picker.
Usage
import {
RecordForm,
RecordFormField,
RecordFormFooter,
RecordFormMessages,
RecordFormRequiredLegend,
RecordFormSection,
RecordFormSubmit,
useRecordFormControl,
} from "@/components/wisty/record-form"RecordForm
├── RecordFormRequiredLegend
├── RecordFormMessages
├── RecordFormSection
│ └── RecordFormField
└── RecordFormFooter (DialogFooter in a dialog)
└── RecordFormSubmitfields describes every field the form can show, keyed by API name; each RecordFormField draws one by name. Saving is the app's: onSubmit gets the values, and the app passes pending and a failed save's errors back in. After Save & New, change the form's key to start again.
const [errors, setErrors] = useState<RecordFormErrors>()
const [pending, startSaving] = useTransition()
const [form, setForm] = useState(0)
<RecordForm
key={form}
fields={fields}
pending={pending}
errors={errors}
onSubmit={(values, { intent }) =>
startSaving(async () => {
const result = await saveContact(values)
setErrors(result.errors)
if (!result.errors && intent === "save-and-new") setForm(form + 1)
})
}
>
…
</RecordForm>highlightChanges marks each field whose value differs from defaultValues, with an undo button that puts the default back, as Lightning's inline edit does. autoFocusField names the field whose control takes focus when the form mounts. onInvalidFields gets the fields the form's own checks stopped a save for, before focus moves, so a page can open the tab or section that hides one. It runs inside flushSync, so a plain setState there has shown the field when the form focuses it.
Values are keyed by API name: text, a number, a percent as a fraction (0.6 for 60%), true or false, a date as "YYYY-MM-DD", a datetime as an ISO 8601 instant ("2026-06-18T14:00:00.000Z"), a time as Salesforce's "HH:mm:ss.SSSZ" ("09:30:00.000Z"), a lookup's record id, an address's or a name's parts, or null for an empty field. A lookup's default value is its record ({ id, name }), so the field can show the name. In an edit, send changed, the second argument's values that differ from defaultValues, to updateRecord. A number is compared as the field shows it, rounded to its scale, so a default of 75000.005 at scale 2 is 75000.01 and isn't changed until someone edits it.
A datetime field is a date and a time on the clock of timeZone, an IANA zone that defaults to "UTC". Pass the user's Salesforce time zone (TimeZoneSidKey), so an event at "2026-06-18T14:00:00.000Z" reads 9:00 AM in "America/Chicago", and what they type saves as the instant it names there. A time field is a time of day with no zone, so timeZone never moves it. Both edit hours and minutes.
An address or a name is one field whose value is its parts: { street, city, state, postalCode, country } or { salutation, firstName, middleName, lastName, suffix }. Gather MailingStreet, MailingCity and the rest into it for defaultValues, and spread the submitted object back out to save, since Salesforce saves the parts, not the compound field. labels names each part, like "Mailing City":
const fields = {
Name: { label: "Name", type: "name", required: true },
MailingAddress: {
label: "Mailing Address",
type: "address",
labels: { street: "Mailing Street", city: "Mailing City" },
},
} satisfies RecordFormFieldsA field whose control the form doesn't draw takes a custom control as RecordFormField's child. The field keeps its label, help, error and undo, and the control calls useRecordFormControl for the rest: value, as the form submits it, onValueChange, and controlProps to spread on the element people type in or pick from, so the label names it and a failed save focuses it. Describe the field in fields by the value it holds: text for a multi-select picklist's "English;Spanish", so the form's required check and changed apply. When the control holds an entry it can't read, pass onValueChange(null, { error }); the save stops there with that message. The Custom control example draws this picklist with Combobox:
<RecordFormField name="Languages__c">
<LanguagesPicklist />
</RecordFormField>To lay out a page layout you load at runtime, flatten each section's rows to field names and map them to parts:
{
layout.sections.map((section) => (
<RecordFormSection
key={section.id}
title={section.heading}
columns={section.columns}
>
{section.fieldNames.map((name) => (
<RecordFormField key={name} name={name} />
))}
</RecordFormSection>
))
}Examples
Edit Opportunity
An opportunity's values in every control: an amount in `currency`, a percent, a close date, and Description in a one-column section.
Name and address
Edit Contact's compound fields: Name as a salutation picklist and four inputs, and Mailing Address as five labelled parts.
New Event
An event's Start and End as datetime fields, each a date and a time, shown on Austin's clock with `timeZone` and submitted as instants.
Custom control
A multi-select picklist the page draws with Combobox, joined to the form by `useRecordFormControl`: required check, error focus and the edited state.
In a dialog
The New Record modal: the form wraps the body and the footer, so the footer's buttons submit it, and Preferred Call Time is a time field.
After a failed save
The app's `errors`: the cleared required name has its message, and a validation rule's message for the record sits above the sections.
Saving
With `pending`, Save shows a spinner and is disabled, so it can't save twice, and every value stays as it was submitted.
API reference
| Part | Built on | Description |
|---|---|---|
RecordForm | <form> (opens in a new tab) | |
RecordFormMessages | Alert | The errors for the whole record, above the sections. Renders nothing without any. |
RecordFormRequiredLegend | <p> (opens in a new tab) | The legend for the required marker: "* = Required Information". |
RecordFormSection | <fieldset> (opens in a new tab) | A titled section of fields, in one or two columns. |
RecordFormField | Field | One field by API name, drawn as its type's control, with its label, help and errors. |
RecordFormFooter | <div> (opens in a new tab) | The row of buttons under the sections on a page. In a dialog, use DialogFooter. |
RecordFormSubmit | Button | Save, or Save & New with intent="save-and-new". Shows a spinner while the save it started is pending. Enter in a field always saves. |
RecordForm
| Prop | Type | Default | Description |
|---|---|---|---|
fields* | RecordFormFields | Every field the form can show, keyed by API name. | |
defaultValues | RecordFormDefaultValues | {} | The record's values. Change the form's key to start again, after Save & New. |
errors | RecordFormErrors | A failed save's errors. Pass a new object for each save, from state. | |
pending | boolean | false | The save is running: Save shows a spinner and the buttons are disabled. |
onSubmit | ( values: RecordFormValues, details: RecordFormSubmitDetails ) => void | Called with valid values when Save or Save & New is pressed. | |
locale | string | "en-US" | A BCP 47 locale for numbers and dates. Default "en-US". |
currency | string | "USD" | An ISO 4217 code for currency fields. Default "USD". |
timeZone | string | "UTC" | An IANA time zone whose clock datetime fields show and are typed in. Default "UTC". |
highlightChanges | boolean | false | Marks each field whose value differs from defaultValues, with an undo button. Default false. |
autoFocusField | string | The API name of the field whose control takes focus when the form mounts. | |
onInvalidFields | (names: string[]) => void | Called with the fields the form's own checks stopped a save for, in page order, before focus moves. |
RecordFormMessages
The errors for the whole record, above the sections. Renders nothing without any.
| Prop | Type | Default |
|---|---|---|
title | string | "We hit a snag." |
RecordFormRequiredLegend
The legend for the required marker: "* = Required Information".
| Prop | Type | Default |
|---|---|---|
children | ReactNode | "Required Information" |
RecordFormSection
A titled section of fields, in one or two columns.
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | The section's heading, as the page layout names it. | |
columns | 1 | 2 | 2 | Two columns, dropping to one in a narrow section. Default 2. |
RecordFormField
One field by API name, drawn as its type's control, with its label, help and errors.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | A custom control that calls useRecordFormControl, never for an address or a name; omit it for the type's own. | |
name* | string | The field's API name, a key of the form's fields. | |
undoLabel | string | "Undo" | The word before the field's label in the undo button's name. Default "Undo". |
layout | "stacked" | "horizontal" | "auto" | "auto" |
RecordFormSubmit
Save, or Save & New with intent="save-and-new". Shows a spinner while the save it started is pending. Enter in a field always saves.
| Prop | Type | Default | Description |
|---|---|---|---|
intent | RecordFormIntent | "save" | Which save the button starts. Default "save". |
useRecordFormControlhook
Joins a custom control to its RecordFormField: the form's checks, changes, undo and focus.
Returns RecordFormControl.
RecordFormOptiontype
A picklist value and its label.
| Member | Type | Default | Description |
|---|---|---|---|
value* | string | What's stored. | |
label* | string | What's shown. |
RecordFormFieldDescriptiontype
How a field is drawn, read and edited: Record detail's description, with what editing needs.
type RecordFormFieldDescription =
| (Described<"text" | "email" | "phone" | "url" | "textarea"> & {
required?: boolean
maxLength?: number
})
| (Described<"number" | "currency" | "percent"> & {
required?: boolean
})
| (Described<"date"> & {
required?: boolean
min?: string
max?: string
})
| (Described<"datetime" | "time"> & {
required?: boolean
})
| (Described<"picklist"> & {
required?: boolean
})
| Described<"boolean">
| (Described<"address"> & {
required?: boolean
labels?: Partial<Record<RecordFormAddressPart, string>>
})
| (Described<"name"> & {
required?: boolean
salutations?: readonly RecordFormOption[]
labels?: Partial<Record<RecordFormNamePart, string>>
})
| (Described<"lookup"> & {
required?: boolean
onSearch?: LookupSearch
icon?: LookupIcon
placeholder?: string
})RecordFormFieldTypetype
A field's type; never "relative", which only a read-only view shows.
type RecordFormFieldType = RecordFormFieldDescription["type"]RecordFormFieldstype
The fields on the form, keyed by API name.
type RecordFormFields = Record<string, RecordFormFieldDescription>RecordFormValuetype
A submitted value: null when empty, a lookup's record id, a percent as a fraction, a datetime as an ISO 8601 instant, a time as "HH:mm:ss.SSSZ", a compound field's parts.
type RecordFormValue =
| string
| number
| boolean
| RecordFormAddressValue
| RecordFormNameValue
| nullRecordFormValuestype
type RecordFormValues = Record<string, RecordFormValue>RecordFormDefaultValuestype
The record's values, keyed by API name: a lookup's is its record, a compound field's its parts.
type RecordFormDefaultValues = RecordDetailValuesRecordFormIntenttype
type RecordFormIntent = "save" | "save-and-new"RecordFormSubmitDetailstype
| Member | Type | Default | Description |
|---|---|---|---|
intent* | RecordFormIntent | The button that saved: Save, or Save & New. | |
changed* | RecordFormValues | The values that differ from defaultValues, as updateRecord takes them. |
RecordFormErrorstype
A failed save's errors, as the app returns them.
| Member | Type | Default | Description |
|---|---|---|---|
form | string | readonly string[] | Errors for the whole record, like a validation rule's. | |
fields | Record<string, string | readonly string[]> | Errors per field, keyed by API name. |
RecordFormControltype
What a custom control inside RecordFormField reads and calls.
| Member | Type | Default | Description |
|---|---|---|---|
value* | RecordFormValue | The value as the form submits it: null when empty, a lookup's id, a percent as a fraction. | |
onValueChange* | (value: RecordFormValue, details?: { error?: string }) => void | Sets the value; pass error with null while the entry isn't a value, and a save stops on it. | |
controlProps* | { id: string "aria-required": true | undefined "aria-invalid": true | undefined "aria-describedby": string | undefined } | Spread on the element people focus, so the label names it and a failed save focuses it. |
SLDS mapping
RecordFormSectionis a staticslds-section__titlebar inslds-theme_shade, its title the size of Expandable section's, over anslds-formofslds-form__rowrows andslds-form__itemitems, as afieldsetwith the title as its legend.RecordFormFieldis a stacked form element (slds-form-element_stacked) with the control for its type: Input for text, email, phone and url, Textarea, Input group with the currency symbol or % for numbers, Date picker, Time picker, Select for a picklist, Lookup, and Checkbox under its label.- A datetime is Datetime picker's compound form element: a FieldSet whose FieldLegend is the field's label, over the date picker and the timepicker (
slds-timepicker) side by side, each with its own label. - An address or a name is a compound form element (
slds-form-element_compound, withslds-form-element_addressfor an address): a FieldSet whose FieldLegend isslds-form-element__legend, overslds-form-element__rowrows of labelled parts. An address's street is a Textarea across, then City beside State/Province and Zip/Postal Code beside Country, four sixths and two. A name's salutation is a Select beside First Name, then Middle Name, then Last Name beside Suffix. - A required field's label starts with
slds-required, andRecordFormRequiredLegendis SLDS's legend for it. Help text isslds-form-element__icon'sutility:infobutton with a tooltip, aslightning-helptextdraws it. RecordFormMessagesis an error Alert (slds-theme_error). Each field's error is its Field's error.- With
highlightChanges, a changed field isslds-is-edited, withslds-form-element__undo'sutility:undobutton at the end of its label's line. RecordFormSubmitis a brand button for Save and a neutral one for Save & New, with Spinner inside while the save runs.
Known gaps:
- No multi-select picklist, rich text or geolocation fields. Draw a scalar one with
useRecordFormControl; an address, a name or a datetime takes no custom control. - A datetime or a time edits hours and minutes. An untouched datetime submits its stored instant as it was. A time's stored seconds are dropped from what the form submits, though an untouched field isn't in
changed. - An address's parts and order are Salesforce's default (en-US) for every country, with free-text state and country; Lightning's country formats and state and country picklists aren't drawn. A name always shows all five parts, where an org without middle names and suffixes shows three.
- Sections don't collapse.
- No read-only fields in edit mode: leave out what people can't edit. Record inline edit draws them beside the form's fields.
- No error popover in the footer ("We hit a snag." next to the buttons). The record's errors show above the sections, as
lightning-messagesdoes. - A number shows as typed, with the symbol beside it, where Lightning formats it when the field loses focus.
Keyboard interactions
| Key | Action |
|---|---|
| Enter in a field | Saves, as Save does, even in a form with Save & New. In a lookup or picklist with its list open, Enter picks the option instead. |
| Escape in a lookup, date or time | Closes its list or calendar first. In a dialog, a second Escape closes the dialog. |
Accessibility
- Every control is named by its label, and each section is a group named by its title.
- An address or a name is a group named by its legend, so a part reads as "Mailing Address, Mailing City". A failed save focuses the first empty required part.
- A datetime is a group named by its label, and its halves are named after it, "Start Date" and "Start Time". A failed save focuses the half that's empty or can't be read, or the date when both are empty.
- A required field's control is
required(a picklist's isaria-required). The asterisk is part of its label. - An invalid field's control is
aria-invalidand described by its error. - A save that fails moves focus to the first field with an error, or to the record's errors when none belongs to a field on the page. Errors the form opens with don't take focus.
- A custom control is named by its field's label, required and invalid through
controlProps, as long as the control spreads them on the element people focus. - A field's help text is its help button's accessible description, so it's announced without opening the tooltip.
- An undo button is named "Undo" and the field's label, like "Undo Amount". It goes away once pressed, so focus moves to the field's control.
- While
pending, the form isaria-busy, and the button that started the save holds a spinner named "Loading".
Responsive behavior
- A two-column section drops to one column when each would be narrower than 280px, by the section's own width, so it fits a narrow panel or modal.
- Labels follow Salesforce's display density: above the controls, and beside them from a 48rem screen when the page is compact (
data-density="compact"on<html>). A field'slayout("stacked"or"horizontal") fixes it for that field.
Rules
- Put in
fieldsonly the fields the form shows. An error for a field the page doesn't draw is listed with the record's errors, under its label, or under its API name when it isn't infields. - Pass
errorsfrom state, a new object for each failed save. An edited field hides its error until the next save. - Leave out a field people can't edit; never pass it to show it disabled.
- Pass
nullfor an empty value, never""or 0. A percent is a fraction: 0.6 for 60%. Divide a Salesforce percent field's 60 by 100. - A required address needs every part, and a required name its last name. The asterisk is on the legend, and on Last Name for a name. Each empty part gets its own "Complete this field."; an error from the app for the field shows under the whole group.
- An address or a name submits every part,
nullfor an empty one, and counts as changed when any part changed. - A form never edits a relative time ("3 hours ago"); that's a read-only view of an instant.
- Pass a datetime as an instant, never as a local date and time, and set
timeZoneto the user's time zone. Pass a time as Salesforce returns it; it's never moved bytimeZone. - A custom control goes inside
RecordFormField, which draws its label, error and undo. SpreadcontrolPropson the element people focus, and never draw a label of its own. - Pass a custom control's entry that isn't a value as
onValueChange(null, { error }), never as a guessed value, so the save stops on it. - Keep the footer inside
RecordForm: Save is a submit button, and Save & New submits the form it's in. - Never pass
asChildorrender.
Related
- Record inline editA record's Details tab, edited in place with a pencil, Cancel and Save.
- Record detailA record's Details tab, read-only, for a standard or custom object.
- FieldLays out a form control with its label, help text and error.
- DialogOpens a window over the page for a short task or a message that needs an answer.
- LookupPicks records from a search, one in the field or several as pills.
- Date pickerPicks a day, typed in the field or chosen from a calendar under it.
- Datetime pickerPicks a date and a time, typed or chosen from a calendar and a list.
- Time pickerPicks a time of day, typed in the field or chosen from a list of times.
- SelectPicks one value from a short list in a dropdown, as a picklist does.