Skip to content

Record form

Lightning's New and Edit record form, with required checks and save errors. The app does the saving.

* = Required Information

Contact Information
Additional Information

Installation

pnpm dlx shadcn@latest add @wisty/record-form

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-form and lightning-record-form draw.
  • 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, updateRecord or 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 useRecordFormControl and 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)
    └── RecordFormSubmit

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

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

* = Required Information

Opportunity Information
$
%
Description Information

Name and address

Edit Contact's compound fields: Name as a salutation picklist and four inputs, and Mailing Address as five labelled parts.

* = Required Information

Contact Information
*Name
Address Information
Mailing Address

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.

* = Required Information

Calendar Details
*Start
*End
Description Information

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.

* = Required Information

Contact Information

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.

* = Required Information

Opportunity Information
$
%
Description Information

Saving

With `pending`, Save shows a spinner and is disabled, so it can't save twice, and every value stays as it was submitted.

* = Required Information

Opportunity Information
$
%
Description Information

API reference

PartBuilt onDescription
RecordForm<form> (opens in a new tab)
RecordFormMessagesAlertThe 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.
RecordFormFieldFieldOne 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.
RecordFormSubmitButtonSave, 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

PropTypeDefaultDescription
fields*RecordFormFieldsEvery field the form can show, keyed by API name.
defaultValuesRecordFormDefaultValues{}The record's values. Change the form's key to start again, after Save & New.
errorsRecordFormErrorsA failed save's errors. Pass a new object for each save, from state.
pendingbooleanfalseThe save is running: Save shows a spinner and the buttons are disabled.
onSubmit( values: RecordFormValues, details: RecordFormSubmitDetails ) => voidCalled with valid values when Save or Save & New is pressed.
localestring"en-US"A BCP 47 locale for numbers and dates. Default "en-US".
currencystring"USD"An ISO 4217 code for currency fields. Default "USD".
timeZonestring"UTC"An IANA time zone whose clock datetime fields show and are typed in. Default "UTC".
highlightChangesbooleanfalseMarks each field whose value differs from defaultValues, with an undo button. Default false.
autoFocusFieldstringThe API name of the field whose control takes focus when the form mounts.
onInvalidFields(names: string[]) => voidCalled 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.

PropTypeDefault
titlestring"We hit a snag."

RecordFormRequiredLegend

The legend for the required marker: "* = Required Information".

PropTypeDefault
childrenReactNode"Required Information"

RecordFormSection

A titled section of fields, in one or two columns.

PropTypeDefaultDescription
title*stringThe section's heading, as the page layout names it.
columns1 | 22Two 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.

PropTypeDefaultDescription
childrenReact.ReactNodeA custom control that calls useRecordFormControl, never for an address or a name; omit it for the type's own.
name*stringThe field's API name, a key of the form's fields.
undoLabelstring"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.

PropTypeDefaultDescription
intentRecordFormIntent"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.

MemberTypeDefaultDescription
value*stringWhat's stored.
label*stringWhat'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
  | null

RecordFormValuestype

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

RecordFormIntenttype

type RecordFormIntent = "save" | "save-and-new"

RecordFormSubmitDetailstype

MemberTypeDefaultDescription
intent*RecordFormIntentThe button that saved: Save, or Save & New.
changed*RecordFormValuesThe values that differ from defaultValues, as updateRecord takes them.

RecordFormErrorstype

A failed save's errors, as the app returns them.

MemberTypeDefaultDescription
formstring | readonly string[]Errors for the whole record, like a validation rule's.
fieldsRecord<string, string | readonly string[]>Errors per field, keyed by API name.

RecordFormControltype

What a custom control inside RecordFormField reads and calls.

MemberTypeDefaultDescription
value*RecordFormValueThe value as the form submits it: null when empty, a lookup's id, a percent as a fraction.
onValueChange*(value: RecordFormValue, details?: { error?: string }) => voidSets 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

  • RecordFormSection is a static slds-section__title bar in slds-theme_shade, its title the size of Expandable section's, over an slds-form of slds-form__row rows and slds-form__item items, as a fieldset with the title as its legend.
  • RecordFormField is 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, with slds-form-element_address for an address): a FieldSet whose FieldLegend is slds-form-element__legend, over slds-form-element__row rows 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, and RecordFormRequiredLegend is SLDS's legend for it. Help text is slds-form-element__icon's utility:info button with a tooltip, as lightning-helptext draws it.
  • RecordFormMessages is an error Alert (slds-theme_error). Each field's error is its Field's error.
  • With highlightChanges, a changed field is slds-is-edited, with slds-form-element__undo's utility:undo button at the end of its label's line.
  • RecordFormSubmit is 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-messages does.
  • 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 is aria-required). The asterisk is part of its label.
  • An invalid field's control is aria-invalid and 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 is aria-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's layout ("stacked" or "horizontal") fixes it for that field.

Rules

  • Put in fields only 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 in fields.
  • Pass errors from 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 null for 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, null for 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 timeZone to the user's time zone. Pass a time as Salesforce returns it; it's never moved by timeZone.
  • A custom control goes inside RecordFormField, which draws its label, error and undo. Spread controlProps on 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 asChild or render.