logo
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:

ModeConstantTriggered when
NewFormDisplayMode.NewUser clicks "New item"
EditFormDisplayMode.EditUser clicks "Edit" on an item
DisplayFormDisplayMode.DisplayUser 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

GitHub

✅ Summary

  • Form Customizer requires SPFx 1.15+ — check your version before scaffolding.
  • this.displayMode tells you whether the form is New, Edit, or Display — adapt the form accordingly.
  • this.context.item provides the current item's field values in Edit and Display modes.
  • Call formSaved() after a successful save and formClosed() on cancel — the base class handles navigation back to the list.
  • Register via Set-PnPList for 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!

Ad image