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 });
  });
}
Initialising PnPjs with 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>
    );
  }
}
React escapes interpolated values by default, so {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

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.

DS

Dhaval Shah

Cloud Solution Architect — 18+ years across SharePoint, Microsoft 365, and Azure AI.