Live training: next batches coming soon. Join the waitlist:Waitlists open:Developer Training →Developer →Copilot Studio Training →Copilot Studio →

How to Display SharePoint List Items in an SPFx Web Part (PnPjs + Fluent UI DetailsList)

Our sales manager asked for one simple thing: the latest orders on the team’s SharePoint home page, in a clean table she could sort by total or date, without opening the list itself. A list view web part came close, but she wanted currency formatting, an order count and no list toolbar.

So we’ll build it. The finished SPFx web part reads the SPGDemo Orders list with PnPjs, shows the rows in a Fluent UI DetailsList with sortable headers, and lets an editor point it at another list from the property pane.

In this tutorial, we’ll scaffold the project with Heft, read the items with PnPjs v4, render them in a DetailsList, test it in the SPGuides workbench with heft start and package it. It’s the full path to display SharePoint list items in SPFx web part projects, plus the same query with SPHttpClient.

Here’s what we’re building:

SPFx Orders Table web part showing SharePoint list items in a Fluent UI DetailsList on a modern page
Tested withVersion
SharePoint Framework (SPFx)1.23.2 with the Heft toolchain
Node.js22 LTS
React / Fluent UI17.0.1 / @fluentui/react 8
PnPjs@pnp/sp 4.22.0

Before You Start

You need a working SPFx 1.23 setup (Node.js 22, Yeoman, the SharePoint generator and Heft) and edit rights on a SharePoint site. If your machine isn’t ready, follow setting up an SPFx project with the Heft toolchain first. Reading list items only needs read permission on the list, so no API approval is involved.

Our data lives in a list called SPGDemo Orders on https://spguides.sharepoint.com/sites/SPGuides. It has 210 orders and these columns:

Column (internal name)TypeNotes
TitleSingle line of textOrder number, for example SO-1102
CustomerNameSingle line of textAdventure Works, Fabrikam Inc., Woodgrove Bank
QuantityNumberUnits ordered
OrderTotalCurrencyUSD, for example $1,890.00
OrderDateDate onlyFor example 1/27/2026
OrderStatusChoiceNew, Processing, Shipped, Delivered
SalesRepPerson or GroupNeeds $expand to read the name
ProductLookupPoints to SPGDemo Products (Laptop Stand, Wireless Mouse, USB-C Dock, Monitor Arm)
SPGDemo Orders SharePoint list with order number, customer, order total, order date, status and product columns

Use internal names, not display names, in your queries. A column renamed after it was created keeps its original internal name; binding list items to a Fluent UI dropdown runs into the same rule.

Step 1: Create the SPFx React Web Part Project

Open a terminal (I use the one inside Visual Studio Code), create a folder and run the SharePoint generator. If this is your first web part ever, building your first SPFx client-side web part explains every generated file:

md spg-orders-table
cd spg-orders-table
yo @microsoft/sharepoint

Answer the prompts like this:

  1. What is your solution name? spg-orders-table
  2. Which type of client-side component to create? WebPart
  3. What is your Web part name? OrdersTable
  4. Which template would you like to use? React

If you script your setups, the same answers fit on one line:

yo @microsoft/sharepoint --solution-name spg-orders-table --component-type webpart --component-name OrdersTable --framework react --environment spo

The generator installs the packages for you (about 5 minutes here). Older posts and projects used gulp serve; since SPFx 1.22 the toolchain is Heft, and new projects get npm run start and npm run build scripts that call it. Have an old gulp project? See migrating from gulp to Heft.

Checkpoint: package.json lists @microsoft/sp-webpart-base 1.23.2, react 17.0.1 and @fluentui/react, and the src/webparts/ordersTable folder exists.

Step 2: Install PnPjs v4

PnPjs wraps the SharePoint REST API in a fluent, typed syntax. Install the SharePoint package with an exact version, so everyone on the team builds the same bundle:

npm install @pnp/sp@4.22.0 --save-exact

Version 4 works with SPFx 1.18 and later without extra configuration. Skip the old sp-pnp-js package and the @pnp/sp/presets/all import that many older samples show; v4 uses small selective imports instead.

Then add two folders under src/webparts/ordersTable: models and services. Here’s the finished file tree in VS Code:

VS Code Explorer showing the SPFx orders table project with models, services and components folders

Step 3: Define the Order Model

Two interfaces keep the code honest: one for the raw item SharePoint sends back, and one flat shape for the table. Create src/webparts/ordersTable/models/IOrder.ts:

// One row of the SPGDemo Orders list, flattened for the table.
export interface IOrder {
  id: number;
  orderNumber: string;
  customer: string;
  product: string;
  quantity: number;
  orderTotal: number;
  orderDate: Date | undefined;
  orderDateText: string;
  status: string;
  salesRep: string;
}

// Raw item shape that SharePoint returns for our $select and $expand.
// Empty columns come back as null, so every column except Id and Title is optional.
export interface IOrderItem {
  Id: number;
  Title: string;
  CustomerName?: string;
  Quantity?: number;
  OrderTotal?: number;
  OrderDate?: string;
  OrderStatus?: string;
  Product?: { Title?: string };
  SalesRep?: { Title?: string };
  FieldValuesAsText?: { OrderDate?: string };
}

The lookup and person columns arrive as nested objects (Product.Title, SalesRep.Title). Flattening them once in the service means the table never has to care.

Step 4: Read the List Items with PnPjs

Next comes the data layer. Create src/webparts/ordersTable/services/OrdersService.ts. It also holds the SPHttpClient version we’ll use later, so here’s the whole file:

import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { spfi, SPFI, SPFx } from '@pnp/sp';
import { HttpRequestError } from '@pnp/queryable';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { IOrder, IOrderItem } from '../models/IOrder';

const SELECT: string[] = [
  'Id', 'Title', 'CustomerName', 'Quantity', 'OrderTotal', 'OrderDate',
  'OrderStatus', 'Product/Title', 'SalesRep/Title', 'FieldValuesAsText/OrderDate'
];
// FieldValuesAsText returns each value formatted the way the list shows it (site time zone and locale).
const EXPAND: string[] = ['Product', 'SalesRep', 'FieldValuesAsText'];

export class OrdersService {
  private _sp: SPFI;
  private _context: WebPartContext;

  constructor(context: WebPartContext) {
    this._context = context;
    // One PnPjs instance per web part, wired to the SPFx context (auth + site URL).
    this._sp = spfi().using(SPFx(context));
  }

  // PnPjs v4: newest orders first, only the columns the table needs.
  public async getOrders(listTitle: string, top: number): Promise<IOrder[]> {
    try {
      const items: IOrderItem[] = await this._sp.web.lists
        .getByTitle(listTitle)
        .items.select(...SELECT)
        .expand(...EXPAND)
        .orderBy('OrderDate', false)
        .top(top)();
      return items.map(OrdersService.toOrder);
    } catch (e) {
      const err = e as HttpRequestError;
      throw new Error(err?.isHttpRequestError ? await OrdersService.errorText(err.response) : (e as Error).message);
    }
  }

  // The same query with SPHttpClient and the SharePoint REST API (no extra package).
  public async getOrdersRest(listTitle: string, top: number): Promise<IOrder[]> {
    const list: string = encodeURIComponent(listTitle.replace(/'/g, "''"));
    const url: string = `${this._context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${list}')/items` +
      `?$select=${SELECT.join(',')}&$expand=${EXPAND.join(',')}&$orderby=OrderDate desc&$top=${top}`;

    const response: SPHttpClientResponse = await this._context.spHttpClient.get(url, SPHttpClient.configurations.v1);
    if (!response.ok) {
      throw new Error(await OrdersService.errorText(response));
    }
    const json: { value: IOrderItem[] } = await response.json();
    return json.value.map(OrdersService.toOrder);
  }

  // SharePoint puts the readable message in odata.error.message.value.
  private static async errorText(response: { status: number; json(): Promise<unknown> }): Promise<string> {
    try {
      const body = (await response.json()) as { 'odata.error'?: { message?: { value?: string } } };
      return body['odata.error']?.message?.value || `Request failed with status ${response.status}`;
    } catch {
      return `Request failed with status ${response.status}`;
    }
  }

  private static toOrder(item: IOrderItem): IOrder {
    return {
      id: item.Id,
      orderNumber: item.Title,
      customer: item.CustomerName || '',
      product: item.Product?.Title || '',
      quantity: item.Quantity || 0,
      orderTotal: item.OrderTotal || 0,
      orderDate: item.OrderDate ? new Date(item.OrderDate) : undefined,
      orderDateText: item.FieldValuesAsText?.OrderDate || '',
      status: item.OrderStatus || '',
      salesRep: item.SalesRep?.Title || ''
    };
  }
}

Here’s what the PnPjs query does:

  • spfi().using(SPFx(context)) creates one PnPjs instance that borrows the web part’s sign-in and site URL. Create it once and reuse it, never inside a click handler.
  • select(...) asks only for the columns we show. Lookup and person columns need the Column/Title form plus expand(...), or SharePoint returns only their IDs.
  • orderBy('OrderDate', false) sorts newest first. The second argument means ascending, so false is descending.
  • top(top) limits the number of rows. Without it, SharePoint returns 100 items.
  • FieldValuesAsText/OrderDate (with expand('FieldValuesAsText')) returns the date exactly as the list displays it, in the site’s time zone. We keep the real date for sorting and show the text.
  • The catch block turns a PnPjs HttpRequestError into SharePoint’s own readable message from odata.error. @pnp/queryable is installed with @pnp/sp, so there’s nothing extra to add.

Checkpoint: The project still compiles with no TypeScript errors. The service doesn’t render anything yet.

Step 5: Show the Items in a Fluent UI DetailsList

DetailsList is the table control behind SharePoint’s own list views, and it ships with SPFx React projects, so there’s nothing to install. For a simpler stacked layout without columns, see the Fluent UI basic list example.

First, replace the props in src/webparts/ordersTable/components/IOrdersTableProps.ts:

import { OrdersService } from '../services/OrdersService';

export interface IOrdersTableProps {
  listTitle: string;
  itemLimit: number;
  useRest: boolean;
  service: OrdersService;
}

Then replace src/webparts/ordersTable/components/OrdersTable.tsx with the component:

import * as React from 'react';
import {
  DetailsList, DetailsListLayoutMode, SelectionMode, IColumn,
  Spinner, SpinnerSize, MessageBar, MessageBarType
} from '@fluentui/react';
import styles from './OrdersTable.module.scss';
import type { IOrdersTableProps } from './IOrdersTableProps';
import { IOrder } from '../models/IOrder';

const usd: Intl.NumberFormat = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

const baseColumns: IColumn[] = [
  { key: 'orderNumber', name: 'Order', fieldName: 'orderNumber', minWidth: 55, maxWidth: 70, isResizable: true },
  { key: 'customer', name: 'Customer', fieldName: 'customer', minWidth: 125, maxWidth: 170, isResizable: true },
  { key: 'product', name: 'Product', fieldName: 'product', minWidth: 85, maxWidth: 120, isResizable: true },
  { key: 'quantity', name: 'Qty', fieldName: 'quantity', minWidth: 25, maxWidth: 40 },
  {
    key: 'orderTotal', name: 'Total', fieldName: 'orderTotal', minWidth: 70, maxWidth: 100,
    onRender: (item: IOrder) => usd.format(item.orderTotal)
  },
  {
    key: 'orderDate', name: 'Date', fieldName: 'orderDate', minWidth: 80, maxWidth: 90,
    // Text from FieldValuesAsText = the same date the list shows; sorting still uses the real Date.
    onRender: (item: IOrder) => item.orderDateText
  },
  { key: 'status', name: 'Status', fieldName: 'status', minWidth: 65, maxWidth: 90 },
  { key: 'salesRep', name: 'Sales Rep', fieldName: 'salesRep', minWidth: 70, maxWidth: 120 }
];

function sortValue(order: IOrder, key: keyof IOrder): string | number | boolean {
  const value = order[key];
  if (value instanceof Date) return value.getTime();
  return value === undefined ? '' : value;
}

function sortOrders(items: IOrder[], key: keyof IOrder, descending: boolean): IOrder[] {
  return [...items].sort((a, b) => {
    const x = sortValue(a, key);
    const y = sortValue(b, key);
    if (x === y) return 0;
    return (x > y ? 1 : -1) * (descending ? -1 : 1);
  });
}

const OrdersTable: React.FC<IOrdersTableProps> = (props) => {
  const { listTitle, itemLimit, useRest, service } = props;
  const [items, setItems] = React.useState<IOrder[]>([]);
  const [loading, setLoading] = React.useState<boolean>(true);
  const [error, setError] = React.useState<string>('');
  const [sortKey, setSortKey] = React.useState<keyof IOrder>('orderDate');
  const [descending, setDescending] = React.useState<boolean>(true);

  // Reload whenever the list title, item limit or data source changes in the property pane.
  React.useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError('');
    const load = useRest ? service.getOrdersRest(listTitle, itemLimit) : service.getOrders(listTitle, itemLimit);
    load
      .then((orders) => { if (!cancelled) { setItems(orders); setLoading(false); } })
      .catch((err: Error) => { if (!cancelled) { setItems([]); setError(err.message); setLoading(false); } });
    return () => { cancelled = true; };
  }, [listTitle, itemLimit, useRest, service]);

  const onColumnClick = (ev: React.MouseEvent<HTMLElement>, column: IColumn): void => {
    const key = column.key as keyof IOrder;
    setDescending(key === sortKey ? !descending : false);
    setSortKey(key);
  };

  const columns: IColumn[] = baseColumns.map((c) => ({
    ...c,
    isSorted: c.key === sortKey,
    isSortedDescending: c.key === sortKey && descending,
    onColumnClick
  }));
  const sorted: IOrder[] = React.useMemo(() => sortOrders(items, sortKey, descending), [items, sortKey, descending]);

  return (
    <section className={styles.ordersTable}>
      <div className={styles.header}>
        <h2 className={styles.title}>{listTitle}</h2>
        {!loading && !error && (
          <span className={styles.count}>{`${items.length} orders loaded with ${useRest ? 'SPHttpClient' : 'PnPjs'}`}</span>
        )}
      </div>
      {loading && <Spinner size={SpinnerSize.large} label="Loading orders..." />}
      {error && (
        <MessageBar messageBarType={MessageBarType.error} isMultiline={true}>
          {`Couldn't load "${listTitle}": ${error}`}
        </MessageBar>
      )}
      {!loading && !error && (
        <DetailsList
          items={sorted}
          columns={columns}
          setKey="id"
          layoutMode={DetailsListLayoutMode.justified}
          selectionMode={SelectionMode.none}
          compact={true}
        />
      )}
    </section>
  );
};

export default OrdersTable;
OrdersTable.tsx open in VS Code with the DetailsList columns and currency formatting for the SPFx web part
  • Columns: each IColumn maps a header to a field. onRender formats the cell: totals show as $1,890.00, and dates show the list’s own text, such as 1/27/2026.
  • Sorting: a header click calls onColumnClick. We flip the direction on a second click and set isSorted so the header shows the arrow.
  • States: a Spinner shows while the request runs, a MessageBar shows the error text if it fails, and the header shows the item count when it works.
  • Reloads: the useEffect dependencies reload the table when an editor changes the list title, item limit or data source.

Want a different date style? My guide to formatting dates in SPFx covers the options. The styles are small; replace OrdersTable.module.scss with:

.ordersTable {
  color: "[theme:bodyText, default: #323130]";
  color: var(--bodyText);
}

.header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 8px;
}

.title {
  font-size: 20px;
  font-weight: 600;
  margin: 0;
}

.count {
  font-size: 13px;
  color: "[theme:neutralSecondary, default: #605e5c]";
}

The [theme:...] tokens pick up the site’s theme colors, so the text stays readable on dark sections too. To read the theme in code instead, see getting the current theme in SPFx.

Step 6: Wire Up the Web Part and Its Property Pane

The web part class creates the service once in onInit and passes it to React. It also adds three property pane fields: the list title, the number of items and a PnPjs or SPHttpClient switch. Replace src/webparts/ordersTable/OrdersTableWebPart.ts:

import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
  type IPropertyPaneConfiguration,
  PropertyPaneTextField,
  PropertyPaneSlider,
  PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';

import * as strings from 'OrdersTableWebPartStrings';
import OrdersTable from './components/OrdersTable';
import { IOrdersTableProps } from './components/IOrdersTableProps';
import { OrdersService } from './services/OrdersService';

export interface IOrdersTableWebPartProps {
  listTitle: string;
  itemLimit: number;
  useRest: boolean;
}

export default class OrdersTableWebPart extends BaseClientSideWebPart<IOrdersTableWebPartProps> {
  private _service!: OrdersService;

  protected async onInit(): Promise<void> {
    await super.onInit();
    this._service = new OrdersService(this.context);
  }

  public render(): void {
    const element: React.ReactElement<IOrdersTableProps> = React.createElement(OrdersTable, {
      listTitle: this.properties.listTitle || 'SPGDemo Orders',
      itemLimit: this.properties.itemLimit || 100,
      useRest: !!this.properties.useRest,
      service: this._service
    });
    ReactDom.render(element, this.domElement);
  }

  // Show an Apply button so the list doesn't reload on every keystroke in the list title box.
  protected get disableReactivePropertyChanges(): boolean {
    return true;
  }

  protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
    if (currentTheme?.semanticColors) {
      this.domElement.style.setProperty('--bodyText', currentTheme.semanticColors.bodyText || null);
    }
  }

  protected onDispose(): void {
    ReactDom.unmountComponentAtNode(this.domElement);
  }

  protected get dataVersion(): Version {
    return Version.parse('1.0');
  }

  protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [
        {
          header: { description: strings.PropertyPaneDescription },
          groups: [
            {
              groupName: strings.BasicGroupName,
              groupFields: [
                PropertyPaneTextField('listTitle', {
                  label: strings.ListTitleFieldLabel,
                  description: 'Display name of a list on this site'
                }),
                PropertyPaneSlider('itemLimit', {
                  label: strings.ItemLimitFieldLabel,
                  min: 10,
                  max: 500,
                  step: 10
                }),
                PropertyPaneToggle('useRest', {
                  label: strings.UseRestFieldLabel,
                  onText: 'SPHttpClient (REST API)',
                  offText: 'PnPjs'
                })
              ]
            }
          ]
        }
      ]
    };
  }
}

disableReactivePropertyChanges adds an Apply button to the pane, so the list doesn’t reload on every keystroke while someone types a title. The SPFx property pane controls guide shows the other field types.

Update the label names in src/webparts/ordersTable/loc/mystrings.d.ts:

declare interface IOrdersTableWebPartStrings {
  PropertyPaneDescription: string;
  BasicGroupName: string;
  ListTitleFieldLabel: string;
  ItemLimitFieldLabel: string;
  UseRestFieldLabel: string;
}

declare module 'OrdersTableWebPartStrings' {
  const strings: IOrdersTableWebPartStrings;
  export = strings;
}

And the English strings in src/webparts/ordersTable/loc/en-us.js:

define([], function() {
  return {
    "PropertyPaneDescription": "Choose the list and how many orders to show.",
    "BasicGroupName": "Data source",
    "ListTitleFieldLabel": "List title",
    "ItemLimitFieldLabel": "Number of items",
    "UseRestFieldLabel": "Load data with"
  }
});

Last, give the web part a friendly name and default values in OrdersTableWebPart.manifest.json. Only the preconfiguredEntries block changes; configuring preconfiguredEntries explains each field:

"preconfiguredEntries": [{
  "groupId": "5c03119e-3074-46fd-976b-c60198311f70",
  "group": { "default": "Advanced" },
  "title": { "default": "Orders Table" },
  "description": { "default": "Shows SPGDemo Orders in a Fluent UI DetailsList" },
  "officeFabricIconFontName": "Table",
  "properties": {
    "listTitle": "SPGDemo Orders",
    "itemLimit": 100,
    "useRest": false
  }
}]

Checkpoint: Saving every file gives no errors, and the toolbox entry will read Orders Table with a table icon.

Step 7: Run It in the SPGuides Workbench with heft start

Before the first run, point the local server at your own site’s workbench, so the list query runs against real data. Open config/serve.json and set initialPage:

{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
  "port": 4321,
  "https": true,
  "initialPage": "https://{tenantDomain}/sites/SPGuides/_layouts/workbench.aspx"
}

Heft swaps {tenantDomain} for the value of the SPFX_SERVE_TENANT_DOMAIN environment variable, so set it once (or type your host name straight into the file); my guide to SPFx environment variables shows how to make it permanent. Then start the local server from the project folder:

# PowerShell, current terminal session
$env:SPFX_SERVE_TENANT_DOMAIN = "spguides.sharepoint.com"
npm run start

Heft builds the project, serves it from https://localhost:4321, prints the debug query string and opens the workbench in your browser:

heft start in the VS Code terminal serving the SPFx web part on localhost 4321 with the debug query string
  1. In the workbench, select the + (add a new web part) button.
  2. Search for Orders Table and select it.
  3. Wait a second for the first request; the table appears below the web part toolbar.
SPGuides site workbench web part toolbox search showing the Orders Table SPFx web part
Orders Table SPFx web part running in the SPGuides site workbench with SharePoint list items in a DetailsList

Checkpoint: The web part shows a spinner for a moment, then 100 orders loaded with PnPjs, newest first. Leave heft start running: when you save a file, Heft rebuilds and a refresh picks up the change.

One heads-up: Microsoft retires the hosted workbench on 12/01/2026. After that, keep heft start running, open a real page such as SitePages/SPGDemo-orders-table.aspx, add ?debugManifestsFile=https%3A%2F%2Flocalhost%3A4321%2Ftemp%2Fbuild%2Fmanifests.js&debug=true&noredir=true to its URL and select Load debug scripts. Your local web part then shows up in that page’s toolbox.

Test the Web Part

With the web part in the workbench, I ran through the three things the sales manager cared about.

Sorting. Click the Total header once for the smallest totals first and again for the largest. In our list, the top order after the second click was SO-1102 for Adventure Works at $1,890.00.

Fluent UI DetailsList in the SPFx web part sorted by the Total column descending with the sort arrow on the header

Property pane. Select the web part’s Edit properties (pencil) icon. Move Number of items to 500 and select Apply; the count changes to 210 orders, the whole list.

SPFx web part property pane with List title, Number of items slider and Load data with toggle

Errors. Type a list name that doesn’t exist, such as SPGDemo Order, and select Apply. Instead of a blank web part, the MessageBar shows SharePoint’s own message:

SPFx web part error MessageBar saying the SharePoint list does not exist at the site

This “does not exist at site” message is the one you’ll see most often in real projects, usually after someone renames a list.

Alternative: Get List Items with SPHttpClient and the REST API

You don’t need PnPjs to read a list. SPFx includes SPHttpClient, which calls the SharePoint REST API with the user’s sign-in already attached. The getOrdersRest method in our service sends this request:

GET https://spguides.sharepoint.com/sites/SPGuides/_api/web/lists/getbytitle('SPGDemo%20Orders')/items
    ?$select=Id,Title,CustomerName,Quantity,OrderTotal,OrderDate,OrderStatus,Product/Title,SalesRep/Title,FieldValuesAsText/OrderDate
    &$expand=Product,SalesRep,FieldValuesAsText
    &$orderby=OrderDate desc
    &$top=100

The method builds that URL from pageContext.web.absoluteUrl, calls spHttpClient.get(url, SPHttpClient.configurations.v1), checks response.ok and reads the rows from json.value. The mapping to IOrder is shared, so the table can’t tell the difference.

To compare them, switch Load data with to SPHttpClient (REST API) in the property pane and select Apply. The same 210 orders come back (the slider was still at 500), and the header confirms the source:

SPFx Orders Table loaded with SPHttpClient and the SharePoint REST API showing the same orders
PnPjs v4SPHttpClient
Extra package@pnp/sp (adds to the bundle)None, part of SPFx
Query styleTyped, chained methodsOData query string you build yourself
Paging and batchingBuilt in (async iteration, batches)Follow odata.nextLink yourself
Best forWeb parts that read and write a lotOne or two simple reads

I pick PnPjs once a web part does more than one or two calls, especially when it also writes data, as in SPFx CRUD operations with React.

Make It Production-Ready

  • Large lists: queries that sort or filter on a column fail past 5,000 items unless that column is indexed. Index OrderDate in the list settings, and page through results instead of raising top. The PnP pagination control pairs well with this.
  • Select only what you show: every extra column, especially expanded lookups, makes the response bigger and the page slower.
  • Security: the web part runs as the signed-in user, so people only see items they can read. Don’t rely on hiding columns to protect data.
  • Reading everything: to process all rows, use PnPjs async iteration, which fetches pages of up to 5,000 items:
// Read every item, 500 per request, without hitting the 5,000-item view threshold.
const all: IOrderItem[] = [];
for await (const page of this._sp.web.lists.getByTitle(listTitle).items.select(...SELECT).expand(...EXPAND).top(500)) {
  all.push(...page);
}

Users will soon ask to narrow the table. I cover server-side filters, such as status or date range, in filtering SharePoint list items in SPFx.

Troubleshooting

  • List ‘SPGDemo Order’ does not exist at site with URL ‘https://spguides.sharepoint.com/sites/SPGuides’. The list title is wrong or the list lives on another site. Check the exact name on the list’s settings page; PnPjs getByTitle uses the display name.
  • The field or property ‘OrderTotl’ does not exist. A column in select uses the display name instead of the internal name, or a typo. Open the column’s settings and read the Field= value in the URL.
  • The query to field ‘Product’ is not valid. You selected Product/Title but forgot expand('Product'). Lookup and person columns need both.
  • Orders Table isn’t in the workbench toolbox: heft start stopped or is still compiling, or the browser blocked https://localhost:4321. Wait for the debug query string line in the terminal, then refresh the workbench.
  • The certificate isn’t trusted: run heft trust-dev-cert once per machine, then restart heft start.
  • Dates look one day off: the REST API returns dates in UTC. Our SO-1002 order comes back as 2026-01-28T00:00:00Z, while the list shows 1/27/2026 in the site’s time zone. Formatting the raw value in the browser can land on either day, so the table shows FieldValuesAsText instead.

Package the Web Part for Production

Once the web part behaves the way you want and the fixes above are in, stop heft start with Ctrl+C and build the production package:

npm run build

The generated script runs heft test --clean --production and then heft package-solution --production, the same two stages Microsoft’s tutorial runs as separate commands. It took about a minute (81 seconds on the very first run) here and wrote sharepoint/solution/spg-orders-table.sppkg:

npm run build in the VS Code terminal creating the spg-orders-table.sppkg SPFx package

That file is ready to ship: upload the .sppkg to the SharePoint app catalog when you’re ready, following Microsoft Learn: Deploy your client-side web part.

Where to Take It Next

  • Let users export: add a button that downloads the current rows, as in exporting data to Excel from SPFx.
  • Use the PnP ListView control: if you’d rather not define columns by hand, the ListView control from @pnp/spfx-controls-react takes items and viewFields and adds a search box and grouping. The older version of this post used it with the GridLayout control; the PnP controls accordion build shows how those controls install.
  • Add a card layout: for a visual dashboard instead of a table, the Fluent UI DocumentCard is a good start.

If you’d like this web part, and many more SPFx samples, as ready-to-run projects, they’re on the Power Platform and SPFx solutions page.

More SharePoint Framework builds that go well with this one:

Frequently Asked Questions

How do I display SharePoint list items in an SPFx web part?

Create a React web part with the SharePoint generator, read the items with PnPjs (spfi().using(SPFx(this.context)) and items.select(…)()) or SPHttpClient, keep them in React state and render them in a Fluent UI DetailsList or your own table.

Should I use PnPjs or SPHttpClient to get list items?

Both call the same SharePoint REST API. SPHttpClient needs no extra package; PnPjs gives typed, chained queries, paging and batching. The PnPjs items documentation shows every query option.

How many items can an SPFx web part show?

One request returns up to 5,000 items with top(). For bigger lists, index the columns you sort or filter on and page through results with PnPjs async iteration or a pagination control.

Can I still test SPFx web parts in the workbench?

Yes, until 12/01/2026, when Microsoft retires the hosted workbench. Set initialPage in config/serve.json to your site’s workbench for now; after that, run heft start and add the debug query string to a real SharePoint page.

Does this web part work with React 18?

SPFx 1.23.2 uses React 17.0.1. SPFx 1.24, currently in preview, moves to React 18. Build production web parts on 1.23.2 until 1.24 is generally available, then upgrade the project.

4 thoughts on “How to Display SharePoint List Items in an SPFx Web Part (PnPjs + Fluent UI DetailsList)”

  1. const items: any[] = await web.lists.getByTitle(“FluentUIDetailsListWithCustomItemColumn”).items.select(“*”, “CustomerName/EMail”, “CustomerName/Title”).expand(“CustomerName/ID”).get();

    Not Working

Leave a Comment