A "no framework" web part is great for learning, but real SPFx development means React for the UI and PnPjs for the data. React gives you a declarative, component-based view layer; PnPjs gives you a fluent, strongly-typed wrapper over the SharePoint REST API that turns ten lines of fetch plumbing into one readable chain. Together they are the productivity backbone of modern SharePoint development.
Scaffolding a React web part
When you run yo @microsoft/sharepoint, choose React as the framework. The generator wires up a web part class plus a React component and an I…Props interface. Then add PnPjs:
# Inside your solution folder
npm install @pnp/sp --save
PnPjs needs to be initialised with the SPFx context once so every call carries the right auth and site URL. Do this in the web part's onInit() and the React component never has to think about authentication again:
import { sp } from "@pnp/sp";
protected onInit(): Promise<void> {
return super.onInit().then(() => {
sp.setup({ spfxContext: this.context as any });
});
}
spfxContext means it inherits the page's identity and the correct web URL. You never hand it a digest or build a header — PnPjs handles the request digest, batching, and OData shaping for you.
Passing context into React
The web part's render() creates the React element and passes whatever the component needs via props — the description, the context, callbacks:
import * as React from 'react';
import * as ReactDom from 'react-dom';
import ContractList from './components/ContractList';
public render(): void {
const element = React.createElement(ContractList, {
listTitle: this.properties.listTitle
});
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
Reading list data with PnPjs
Inside the React component, fetch on mount. Note how $select/$expand/$filter from the REST article become fluent methods — and the result is typed:
import * as React from 'react';
import { sp } from "@pnp/sp";
interface IContract { Id: number; Title: string; ContractStatus: string; AuthorName: string; }
interface IState { items: IContract[]; loading: boolean; }
export default class ContractList
extends React.Component<{ listTitle: string }, IState> {
public state: IState = { items: [], loading: true };
public async componentDidMount(): Promise<void> {
const data = await sp.web.lists
.getByTitle(this.props.listTitle)
.items
.select("Id", "Title", "ContractStatus", "Author/Title")
.expand("Author")
.filter("ContractStatus eq 'Approved'")
.orderBy("Modified", false)
.top(50)();
this.setState({
items: data.map(d => ({
Id: d.Id, Title: d.Title,
ContractStatus: d.ContractStatus,
AuthorName: d.Author?.Title
})),
loading: false
});
}
public render(): React.ReactElement {
if (this.state.loading) return <p>Loading…</p>;
return (
<ul>
{this.state.items.map(c =>
<li key={c.Id}><strong>{c.Title}</strong> — {c.ContractStatus} ({c.AuthorName})</li>
)}
</ul>
);
}
}
{c.Title} is safe against XSS out of the box — a real advantage over the string-concatenation rendering of JSLink. You only need to think about encoding when you reach for dangerouslySetInnerHTML (and the name is a warning).
Writing data
Creating and updating items is equally terse. PnPjs handles the digest and the entity type automatically:
// Create
const add = await sp.web.lists.getByTitle("Contracts").items.add({
Title: "Acme MSA 2018",
ContractStatus: "Draft"
});
// Update item 42
await sp.web.lists.getByTitle("Contracts").items
.getById(42)
.update({ ContractStatus: "Approved" });
// Delete
await sp.web.lists.getByTitle("Contracts").items.getById(42).delete();
The property pane for configuration
Hard-coding a list title is fine for a demo; real web parts expose it. Add a text (or, better, list-picker) field to the property pane and pass this.properties.listTitle into the component. When the user edits it, SPFx re-runs render() and React reconciles the change — no manual DOM work.
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [{
header: { description: "Configure the contracts web part" },
groups: [{
groupName: "Data",
groupFields: [
PropertyPaneTextField('listTitle', { label: 'List title' })
]
}]
}]
};
}
Why this stack wins
- Less code, fewer bugs. PnPjs removes the digest/entity-type/header ceremony that the raw REST API demands.
- Type safety. TypeScript interfaces catch field-name typos at build time instead of at runtime.
- Declarative UI. React state drives the view; you describe what the UI should look like, not how to mutate the DOM.
- Built-in batching. PnPjs can batch many operations into a single
$batchrequest with almost no extra code.
Once React and PnPjs are second nature, the rest of the SPFx story — Fluent UI for polished components, the AAD/Graph clients for Microsoft 365 data, and extensions for surfacing UI outside web parts — slots neatly on top. This is the core loop you'll use on virtually every modern SharePoint project.