logo
Published on

SPFx Command Set — Adding Custom Toolbar Buttons

A List View Command Set adds buttons to the SharePoint list toolbar and right-click context menu. The button can be always visible, appear only when items are selected, or change its label based on how many items are selected.
This article builds a practical bulk-edit command set — a "Edit Selected" button that opens a panel form allowing the user to update a field on all selected items at once.


📦 Scaffold the Extension

yo @microsoft/sharepoint

When prompted:

  • What type of client-side component? → Extension
  • Which type of client-side extension? → ListView Command Set
  • What is your Command Set name?BulkEdit
  • Framework? → No JavaScript framework (we will add React manually)

⚙️ Command Set Lifecycle

MethodWhen it firesWhat to do
onInit()Once on loadSet up PnPjs, initialise state
onListViewUpdated(event)Every time selection changesShow/hide/enable/disable buttons
onExecute(event)When a button is clickedPerform the action

The onListViewUpdated method is where you make commands context-sensitive — checking event.selectedRows to enable a button only when the right items are selected.


🧩 Command Set Manifest

The manifest declares the buttons (called "commands") and their default labels. Located at src/extensions/bulkEdit/BulkEditCommandSet.manifest.json:

{
  "id": "your-component-id",
  "alias": "BulkEditCommandSet",
  "componentType": "Extension",
  "extensionType": "ListViewCommandSet",
  "version": "*",
  "manifestVersion": 2,
  "items": {
    "BULK_EDIT_STATUS": {
      "title": { "default": "Edit Status" },
      "iconImageUrl": "https://spoprod-a.akamaihd.net/files/fabric/assets/item-types/16/genericfile.png",
      "type": "command"
    }
  }
}

🧩 Command Set Class

src/extensions/bulkEdit/BulkEditCommandSet.ts

import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import {
  BaseListViewCommandSet,
  Command,
  IListViewCommandSetExecuteEventParameters,
  IListViewCommandSetListViewUpdatedParameters,
  RowAccessor
} from '@microsoft/sp-listview-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 BulkEditPanel from './components/BulkEditPanel';

export default class BulkEditCommandSet
  extends BaseListViewCommandSet<{}> {

  private _sp: SPFI;
  private _panelContainer: HTMLDivElement;

  @override
  public onInit(): Promise<void> {
    this._sp = spfi().using(SPFx(this.context));

    // Create a persistent container for the panel outside the list view DOM
    this._panelContainer = document.createElement('div');
    document.body.appendChild(this._panelContainer);

    return Promise.resolve();
  }

  @override
  public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void {
    const bulkEditCommand: Command = this.tryGetCommand('BULK_EDIT_STATUS');

    if (bulkEditCommand) {
      // Show button only when 1 or more items are selected
      const count = event.selectedRows.length;
      bulkEditCommand.visible = count > 0;
      bulkEditCommand.title = count === 1
        ? 'Edit Status'
        : `Edit Status (${count} items)`;
    }
  }

  @override
  public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
    if (event.itemId === 'BULK_EDIT_STATUS') {
      const selectedIds = event.selectedRows.map(
        (row: RowAccessor) => row.getValueByName('ID') as string
      );

      this._openBulkEditPanel(selectedIds);
    }
  }

  private _openBulkEditPanel(selectedIds: string[]): void {
    ReactDOM.render(
      React.createElement(
        FluentProvider,
        { theme: webLightTheme },
        React.createElement(BulkEditPanel, {
          sp: this._sp,
          listName: this.context.pageContext.list?.title ?? '',
          selectedIds,
          onDismiss: () => this._closeBulkEditPanel(),
          onSaved: () => {
            this._closeBulkEditPanel();
            // Reload the list view to reflect changes
            window.location.reload();
          }
        })
      ),
      this._panelContainer
    );
  }

  private _closeBulkEditPanel(): void {
    ReactDOM.unmountComponentAtNode(this._panelContainer);
  }

  public onDispose(): void {
    ReactDOM.unmountComponentAtNode(this._panelContainer);
    document.body.removeChild(this._panelContainer);
    super.onDispose();
  }
}

🧩 Bulk Edit Panel Component

src/extensions/bulkEdit/components/BulkEditPanel.tsx

import * as React from 'react';
import {
  DrawerBody,
  DrawerHeader,
  DrawerHeaderTitle,
  InlineDrawer,
  Button,
  Field,
  Dropdown,
  Option,
  MessageBar,
  Spinner,
  makeStyles,
  tokens
} from '@fluentui/react-components';
import { DismissRegular } from '@fluentui/react-icons';
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';

const useStyles = makeStyles({
  actions: {
    display: 'flex',
    gap: tokens.spacingHorizontalS,
    paddingTop: tokens.spacingVerticalL
  }
});

interface IBulkEditPanelProps {
  sp: SPFI;
  listName: string;
  selectedIds: string[];
  onDismiss: () => void;
  onSaved: () => void;
}

const STATUS_OPTIONS = ['Not Started', 'In Progress', 'Blocked', 'Completed'];

const BulkEditPanel: React.FC<IBulkEditPanelProps> = ({
  sp, listName, selectedIds, onDismiss, onSaved
}) => {
  const styles = useStyles();
  const [newStatus, setNewStatus] = React.useState('');
  const [saving, setSaving] = React.useState(false);
  const [error, setError] = React.useState<string | null>(null);

  const handleSave = async () => {
    if (!newStatus) return;
    setSaving(true);
    setError(null);

    try {
      // Update all selected items in sequence
      for (const id of selectedIds) {
        await sp.web.lists
          .getByTitle(listName)
          .items
          .getById(parseInt(id))
          .update({ Status: newStatus });
      }
      onSaved();
    } catch (err) {
      setError('Failed to update some items. Please try again.');
      setSaving(false);
    }
  };

  return (
    <InlineDrawer open={true} position="end" size="small">
      <DrawerHeader>
        <DrawerHeaderTitle
          action={
            <Button
              appearance="subtle"
              icon={<DismissRegular />}
              onClick={onDismiss}
            />
          }
        >
          Edit Status — {selectedIds.length} item{selectedIds.length !== 1 ? 's' : ''}
        </DrawerHeaderTitle>
      </DrawerHeader>

      <DrawerBody>
        {error && (
          <MessageBar intent="error">{error}</MessageBar>
        )}

        <Field label="New Status" required>
          <Dropdown
            placeholder="Select a status..."
            value={newStatus}
            onOptionSelect={(_, data) => setNewStatus(data.optionValue ?? '')}
          >
            {STATUS_OPTIONS.map(opt => (
              <Option key={opt} value={opt}>{opt}</Option>
            ))}
          </Dropdown>
        </Field>

        <div className={styles.actions}>
          <Button
            appearance="primary"
            onClick={handleSave}
            disabled={!newStatus || saving}
          >
            {saving ? <Spinner size="tiny" /> : `Update ${selectedIds.length} item${selectedIds.length !== 1 ? 's' : ''}`}
          </Button>
          <Button onClick={onDismiss} disabled={saving}>Cancel</Button>
        </div>
      </DrawerBody>
    </InlineDrawer>
  );
};

export default BulkEditPanel;

🚀 Register the Command Set on a List

Via PnP PowerShell:

Add-PnPCustomAction `
  -Title "BulkEditStatus" `
  -Name "BulkEditStatus" `
  -Location "ClientSideExtension.ListViewCommandSet.CommandBar" `
  -ClientSideComponentId "your-component-id-from-manifest.json" `
  -ClientSideComponentProperties '{}' `
  -List "Projects"

The Location controls where the button appears:

  • CommandBar — main toolbar at the top of the list view
  • ContextMenu — right-click context menu on item rows
  • CommandBar,ContextMenu — both (requires separate Add-PnPCustomAction calls)

🚀 Deploy

npm run build
npm run bundle -- --ship
npm run package-solution -- --ship

📂 GitHub Source

View full SPFx project on GitHub:SPFx command set — bulk edit selected list items with panel form

GitHub

✅ Summary

  • onListViewUpdated fires on every selection change — use it to set command.visible and command.title based on event.selectedRows.length.
  • Render panels into a <div> appended to document.body — not into the list view DOM, which SharePoint manages and may replace.
  • Unmount and remove the panel container in onDispose to avoid memory leaks.
  • Use row.getValueByName('ID') to get the list item ID from the selected rows.
  • Register on CommandBar for the toolbar, ContextMenu for right-click, or both via separate Add-PnPCustomAction calls.

Happy coding!

Ad image