- 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
✅ Summary
- Import
@pnp/sp/taxonomyas 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
validateUpdateListItemwith the-1;#<label>|<guid>format. - The
TaxonomyPickercomponent is fully reusable — pass differenttermSetIdvalues to power any managed metadata field in your solution.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
