- Published on
SPFx Form Customizer — Replace NewForm and EditForm
The SPFx Form Customizer is the newest and most powerful extension type — it replaces SharePoint's default New Item, Edit Item, and Display Item forms entirely with a custom React component.
Unlike a web part embedded on a page, the form customizer loads directly as the form for a list. Users click "New" or "Edit" on a list and see your component, not the default form.
This article builds a complete form customizer — new and edit modes, field validation, Fluent UI v9, and save via PnPjs.
📦 Scaffold the Extension
yo @microsoft/sharepoint
When prompted:
- What type of client-side component? → Extension
- Which type of client-side extension? → Form Customizer
- What is your Form Customizer name? →
ProjectForm - Framework? → React
SPFx 1.15+ is required for Form Customizer. Check your SPFx version with npm list @microsoft/sp-core-library.
⚙️ Form Customizer Modes
The form customizer handles three modes via this.displayMode:
| Mode | Constant | Triggered when |
|---|---|---|
| New | FormDisplayMode.New | User clicks "New item" |
| Edit | FormDisplayMode.Edit | User clicks "Edit" on an item |
| Display | FormDisplayMode.Display | User clicks to view an item (read-only) |
In Edit and Display modes, this.context.item contains the current item's field values.
🧩 Form Customizer Class
src/extensions/projectForm/ProjectFormCustomizer.ts
import { Log } from '@microsoft/sp-core-library';
import {
BaseFormCustomizer,
FormDisplayMode
} from '@microsoft/sp-form-extensibility';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { FluentProvider, webLightTheme } from '@fluentui/react-components';
import { spfi, SPFx, SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import ProjectForm from './components/ProjectForm';
export interface IProjectFormProperties {
// Extension-level properties if needed
}
export default class ProjectFormCustomizer
extends BaseFormCustomizer<IProjectFormProperties> {
private _sp: SPFI;
public async onInit(): Promise<void> {
await super.onInit();
this._sp = spfi().using(SPFx(this.context));
}
public render(): void {
// In Edit/Display mode, this.context.item has the current values
const existingItem = this.displayMode !== FormDisplayMode.New
? this.context.item
: null;
ReactDOM.render(
React.createElement(
FluentProvider,
{ theme: webLightTheme },
React.createElement(ProjectForm, {
sp: this._sp,
listId: this.context.list.guid.toString(),
itemId: existingItem?.['ID'] as number | undefined,
displayMode: this.displayMode,
initialValues: existingItem ?? {},
onSave: this._onSave,
onClose: this._onClose
})
),
this.domElement
);
}
public onDispose(): void {
ReactDOM.unmountComponentAtNode(this.domElement);
super.onDispose();
}
// Called when the form saves — tells SharePoint to close the form
private _onSave = (): void => {
this.formSaved();
};
// Called when the user cancels — tells SharePoint to close the form
private _onClose = (): void => {
this.formClosed();
};
}
The formSaved() and formClosed() methods are provided by the base class. They signal SharePoint to navigate back to the list view — the form customizer does not control navigation itself.
🧩 Project Form Component
src/extensions/projectForm/components/ProjectForm.tsx
import * as React from 'react';
import {
Button, Field, Input, Dropdown, Option,
Textarea, MessageBar, Spinner,
makeStyles, tokens
} from '@fluentui/react-components';
import { FormDisplayMode } from '@microsoft/sp-form-extensibility';
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
const useStyles = makeStyles({
form: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalL,
maxWidth: '680px',
padding: tokens.spacingVerticalL
},
actions: {
display: 'flex',
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalM,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`
}
});
interface IProjectFormProps {
sp: SPFI;
listId: string;
itemId?: number;
displayMode: FormDisplayMode;
initialValues: Record<string, unknown>;
onSave: () => void;
onClose: () => void;
}
interface IFormState {
title: string;
description: string;
status: string;
priority: string;
}
interface IFormErrors {
title?: string;
status?: string;
}
const STATUS_OPTIONS = ['Not Started', 'In Progress', 'Blocked', 'Completed'];
const PRIORITY_OPTIONS = ['Low', 'Medium', 'High', 'Critical'];
const ProjectForm: React.FC<IProjectFormProps> = ({
sp, listId, itemId, displayMode, initialValues, onSave, onClose
}) => {
const styles = useStyles();
const isReadOnly = displayMode === FormDisplayMode.Display;
const [form, setForm] = React.useState<IFormState>({
title: (initialValues['Title'] as string) ?? '',
description: (initialValues['Description'] as string) ?? '',
status: (initialValues['Status'] as string) ?? 'Not Started',
priority: (initialValues['Priority'] as string) ?? 'Medium'
});
const [errors, setErrors] = React.useState<IFormErrors>({});
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const validate = (): boolean => {
const newErrors: IFormErrors = {};
if (!form.title.trim()) newErrors.title = 'Title is required.';
if (!form.status) newErrors.status = 'Status is required.';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate()) return;
setSaving(true);
setSaveError(null);
try {
const payload = {
Title: form.title.trim(),
Description: form.description,
Status: form.status,
Priority: form.priority
};
if (displayMode === FormDisplayMode.New) {
await sp.web.lists.getById(listId).items.add(payload);
} else if (itemId) {
await sp.web.lists.getById(listId).items.getById(itemId).update(payload);
}
onSave();
} catch (err) {
setSaveError('Failed to save the item. Please try again.');
setSaving(false);
}
};
const setField = (field: keyof IFormState) => (value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (errors[field as keyof IFormErrors]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
return (
<div className={styles.form}>
<h2>
{displayMode === FormDisplayMode.New ? 'New Project' :
displayMode === FormDisplayMode.Edit ? 'Edit Project' : 'Project Details'}
</h2>
{saveError && <MessageBar intent="error">{saveError}</MessageBar>}
<Field
label="Title"
required
validationMessage={errors.title}
validationState={errors.title ? 'error' : 'none'}
>
<Input
value={form.title}
onChange={(_, d) => setField('title')(d.value)}
readOnly={isReadOnly}
/>
</Field>
<Field label="Description">
<Textarea
value={form.description}
onChange={(_, d) => setField('description')(d.value)}
readOnly={isReadOnly}
rows={4}
/>
</Field>
<Field
label="Status"
required
validationMessage={errors.status}
validationState={errors.status ? 'error' : 'none'}
>
<Dropdown
value={form.status}
onOptionSelect={(_, d) => setField('status')(d.optionValue ?? '')}
disabled={isReadOnly}
>
{STATUS_OPTIONS.map(opt => <Option key={opt} value={opt}>{opt}</Option>)}
</Dropdown>
</Field>
<Field label="Priority">
<Dropdown
value={form.priority}
onOptionSelect={(_, d) => setField('priority')(d.optionValue ?? '')}
disabled={isReadOnly}
>
{PRIORITY_OPTIONS.map(opt => <Option key={opt} value={opt}>{opt}</Option>)}
</Dropdown>
</Field>
<div className={styles.actions}>
{!isReadOnly && (
<Button
appearance="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? <Spinner size="tiny" /> : 'Save'}
</Button>
)}
<Button onClick={onClose} disabled={saving}>
{isReadOnly ? 'Close' : 'Cancel'}
</Button>
</div>
</div>
);
};
export default ProjectForm;
🚀 Register the Form Customizer on a List
Via PnP PowerShell (register for all three form types):
$componentId = "your-component-id-from-manifest.json"
# New form
Set-PnPList -Identity "Projects" -NewFormClientSideComponentId $componentId
Set-PnPList -Identity "Projects" -NewFormClientSideComponentProperties '{}'
# Edit form
Set-PnPList -Identity "Projects" -EditFormClientSideComponentId $componentId
Set-PnPList -Identity "Projects" -EditFormClientSideComponentProperties '{}'
# Display form
Set-PnPList -Identity "Projects" -DisplayFormClientSideComponentId $componentId
Set-PnPList -Identity "Projects" -DisplayFormClientSideComponentProperties '{}'
You can register the form customizer for all three form types, or just new/edit while leaving display as the default. The component ID is the same for all three — displayMode inside the component handles the different behaviours.
🚀 Deploy
npm run build
npm run bundle -- --ship
npm run package-solution -- --ship
📂 GitHub Source
View full SPFx project on GitHub:SPFx form customizer — custom React form with validation, Fluent UI v9 and PnPjs save
✅ Summary
- Form Customizer requires SPFx 1.15+ — check your version before scaffolding.
this.displayModetells you whether the form is New, Edit, or Display — adapt the form accordingly.this.context.itemprovides the current item's field values in Edit and Display modes.- Call
formSaved()after a successful save andformClosed()on cancel — the base class handles navigation back to the list. - Register via
Set-PnPListfor new, edit, and display forms independently — you can replace all three or just some. - Validate before save: required fields, format constraints, and business rules — SharePoint does no client-side validation of your custom form.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
