logo
Published on

Working with SharePoint Taxonomy Terms in SPFx

SharePoint's Managed Metadata Service is one of those features that every enterprise uses but few developers enjoy working with. The API is verbose, the term hierarchy is nested, and finding the right PnPjs methods requires digging through documentation that has not always kept pace with v3.
This article cuts through the noise: how to read term sets, build a flat list of terms for a picker, and wrap it all in a reusable TaxonomyPicker component backed by Fluent UI v9's Combobox.


📦 Install Required Packages

npm install @pnp/sp --save
npm install @fluentui/react-components --save

Import the taxonomy sub-module — without this, PnPjs does not expose the term store API:

import '@pnp/sp/taxonomy';

⚙️ Reading Term Sets with PnPjs v3

The term store API in PnPjs v3 has changed significantly from v2. Terms are accessed via sp.termStore, not sp.taxonomy.

Get all term sets in the default site collection group:

import { SPFI } from '@pnp/sp';
import '@pnp/sp/taxonomy';

const sp: SPFI = spfi().using(SPFx(this.context));

// Get term sets available to this site
const termSets = await sp.termStore.sets();
console.log(termSets);

Get terms from a specific term set by ID:

const TERM_SET_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'; // Your term set GUID

const terms = await sp.termStore.sets
  .getById(TERM_SET_ID)
  .terms();

// terms is a flat array — parent/child relationships are in .parent property
terms.forEach(term => {
  console.log(term.id, term.labels[0]?.name, term.parent?.id);
});

Get terms with children (hierarchical):

const termSet = await sp.termStore.sets.getById(TERM_SET_ID).expand('children($levels=max)')();

Note: Deep expand is limited to a few levels in the REST API. For large hierarchies, fetch all terms flat and reconstruct the tree client-side.


🗂️ Flattening a Term Hierarchy

For a picker component, a flat sorted list is usually more usable than a nested tree. Here is a utility that flattens the hierarchy with indented labels:

export interface ITermOption {
  id: string;
  label: string;         // Display name
  displayLabel: string;  // Indented label for hierarchy visualisation
  parentId: string | null;
  depth: number;
}

export function flattenTerms(
  terms: any[],
  parentId: string | null = null,
  depth: number = 0
): ITermOption[] {
  const result: ITermOption[] = [];

  const children = terms.filter(t =>
    (t.parent?.id ?? null) === parentId
  );

  children.forEach(term => {
    const label = term.labels?.find((l: any) => l.isDefault)?.name
      ?? term.labels?.[0]?.name
      ?? term.id;

    result.push({
      id: term.id,
      label,
      displayLabel: '\u00A0\u00A0'.repeat(depth * 2) + label, // Non-breaking spaces for indent
      parentId,
      depth
    });

    // Recursively add children
    result.push(...flattenTerms(terms, term.id, depth + 1));
  });

  return result;
}

🧩 TaxonomyPicker Component

A reusable picker that loads terms on mount and exposes the selected term via an onChange callback:

src/components/TaxonomyPicker.tsx

import * as React from 'react';
import {
  Combobox,
  Option,
  Field,
  Spinner,
  makeStyles,
  tokens
} from '@fluentui/react-components';
import { SPFI } from '@pnp/sp';
import '@pnp/sp/taxonomy';
import { Caching } from '@pnp/queryable';
import { ITermOption, flattenTerms } from '../utils/taxonomyUtils';

const useStyles = makeStyles({
  picker: { minWidth: '280px' },
  indent: { color: tokens.colorNeutralForeground3 }
});

export interface ITaxonomyPickerProps {
  sp: SPFI;
  termSetId: string;
  label: string;
  placeholder?: string;
  selectedId?: string;
  onChange: (term: ITermOption | null) => void;
}

const TaxonomyPicker: React.FC<ITaxonomyPickerProps> = ({
  sp,
  termSetId,
  label,
  placeholder = 'Select a term...',
  selectedId,
  onChange
}) => {
  const styles = useStyles();
  const [terms, setTerms] = React.useState<ITermOption[]>([]);
  const [loading, setLoading] = React.useState(true);
  const [value, setValue] = React.useState('');

  React.useEffect(() => {
    // Cache taxonomy calls for 60 minutes — terms rarely change
    const cachedSp = sp.using(
      Caching({
        store: 'session',
        expireFunc: () => new Date(Date.now() + 60 * 60 * 1000)
      })
    );

    cachedSp.termStore.sets
      .getById(termSetId)
      .terms()
      .then(rawTerms => {
        const flat = flattenTerms(rawTerms);
        setTerms(flat);

        // If a selectedId is passed, pre-populate the input
        if (selectedId) {
          const match = flat.find(t => t.id === selectedId);
          if (match) setValue(match.label);
        }
        setLoading(false);
      })
      .catch(() => setLoading(false));
  }, [termSetId]);

  if (loading) return <Spinner size="tiny" label="Loading terms..." />;

  return (
    <Field label={label}>
      <Combobox
        className={styles.picker}
        placeholder={placeholder}
        value={value}
        onOptionSelect={(_, data) => {
          const selected = terms.find(t => t.id === data.optionValue);
          setValue(selected?.label ?? '');
          onChange(selected ?? null);
        }}
        onChange={e => setValue(e.target.value)}
      >
        {terms.map(term => (
          <Option
            key={term.id}
            value={term.id}
            text={term.label}
          >
            <span>
              {term.depth > 0 && (
                <span className={styles.indent}>
                  {'— '.repeat(term.depth)}
                </span>
              )}
              {term.label}
            </span>
          </Option>
        ))}
      </Combobox>
    </Field>
  );
};

export default TaxonomyPicker;

🔧 Using the TaxonomyPicker in a Web Part

import * as React from 'react';
import { SPFI } from '@pnp/sp';
import TaxonomyPicker from '../../components/TaxonomyPicker';
import { ITermOption } from '../../utils/taxonomyUtils';

const DEPARTMENT_TERM_SET_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

const NewProjectForm: React.FC<{ sp: SPFI }> = ({ sp }) => {
  const [selectedDept, setSelectedDept] = React.useState<ITermOption | null>(null);

  return (
    <div>
      <TaxonomyPicker
        sp={sp}
        termSetId={DEPARTMENT_TERM_SET_ID}
        label="Department"
        placeholder="Select a department..."
        onChange={term => {
          setSelectedDept(term);
          console.log('Selected term:', term?.id, term?.label);
        }}
      />

      {selectedDept && (
        <p>Selected: {selectedDept.label} (ID: {selectedDept.id})</p>
      )}
    </div>
  );
};

export default NewProjectForm;

💾 Saving a Taxonomy Field Value

When saving a term selection back to a SharePoint managed metadata field, the value must be in the correct wire format. PnPjs handles this via the validateUpdateListItem approach:

// Save the selected term to a managed metadata field
await sp.web.lists
  .getByTitle('Projects')
  .items
  .getById(itemId)
  .validateUpdateListItem([
    {
      FieldName: 'Department',           // Internal name of the managed metadata field
      FieldValue: `-1;#${selectedDept.label}|${selectedDept.id}`
    }
  ]);

The format is -1;#<term label>|<term GUID>. The -1 is a placeholder WssId that SharePoint replaces with the real term store ID on save. Using validateUpdateListItem is safer than update() for managed metadata fields because it handles the WssId resolution automatically.


🚀 Deploy

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

📂 GitHub Source

View full SPFx project on GitHub:SPFx taxonomy picker component — PnPjs v3 + React + Fluent UI v9 ComboBox

GitHub

✅ Summary

  • Import @pnp/sp/taxonomy as a side effect — the taxonomy API is not available without it.
  • Use sp.termStore.sets.getById(termSetId).terms() to fetch all terms in a set as a flat array.
  • Reconstruct the hierarchy client-side from the flat array using term.parent?.id — do not rely on deep expand for large term sets.
  • Cache taxonomy calls for 60 minutes in session storage — term sets change rarely and the calls are slow.
  • Save managed metadata field values using validateUpdateListItem with the -1;#<label>|<guid> format.
  • The TaxonomyPicker component is fully reusable — pass different termSetId values to power any managed metadata field in your solution.

Happy coding!

Ad image