- Published on
PnPjs v3 Setup in SPFx — Complete Guide
PnPjs v3 is a ground-up rewrite of the library that many SPFx developers know from v2. The fluent chain API looks familiar, but the initialisation model is completely different — and if you set it up the wrong way, you end up with a client that silently uses the wrong context, ignores caching, or breaks in the hosted workbench.
This article covers everything you need to configure PnPjs v3 correctly in an SPFx project — from package installation to a production-ready service layer.
📦 Install the Required Packages
PnPjs v3 is modular. Install only what you need:
# Core SharePoint client
npm install @pnp/sp --save
# Graph client (optional — only if you need Graph via PnPjs)
npm install @pnp/graph --save
# SPFx behaviours — provides context injection for SPFx
npm install @pnp/sp --save
# Logging (optional but recommended)
npm install @pnp/logging --save
The SPFx behaviour is bundled inside @pnp/sp as of v3 — no separate SPFx package needed. The SPFx() behaviour is imported directly from @pnp/sp.
⚙️ The Critical Difference from v2 — Behaviour-Based Configuration
In PnPjs v2, you called sp.setup({ spfxContext: this.context }) once globally. That global singleton caused issues when multiple web parts on the same page used different contexts.
In v3, every spfi() instance is configured via behaviours — composable functions you chain with .using(). There is no global state. Each instance is self-contained.
import { spfi, SPFx } from '@pnp/sp';
// v3: create a configured instance
const sp = spfi().using(SPFx(this.context));
// This instance is scoped to this.context — safe to use alongside other web parts
This is the most important conceptual shift from v2. Every service or component that needs sp should receive a pre-configured instance, not call spfi() independently.
🔧 Setting Up sp and graph in onInit
Create the clients once in onInit and pass them to your service layer:
import { SPFI, spfi, SPFx } from '@pnp/sp';
import { GraphFI, graphfi, SPFx as GraphSPFx } from '@pnp/graph';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users';
export default class MyWebPart extends BaseClientSideWebPart<IMyWebPartProps> {
private _sp: SPFI;
private _graph: GraphFI;
protected async onInit(): Promise<void> {
await super.onInit();
this._sp = spfi().using(SPFx(this.context));
this._graph = graphfi().using(GraphSPFx(this.context));
}
public render(): void {
const element = React.createElement(MyComponent, {
sp: this._sp,
graph: this._graph
});
ReactDOM.render(element, this.domElement);
}
}
Important: Always import the sub-modules you need (e.g. @pnp/sp/lists, @pnp/sp/items) as side-effect imports. Without these, PnPjs v3 does not know about the corresponding fluent chain methods — you will get TypeScript errors or undefined is not a function at runtime.
🧩 Sub-module Import Reference
Only import what your solution uses — each import adds to your bundle size:
// Web and site
import '@pnp/sp/webs';
import '@pnp/sp/site-collections';
// Lists and items
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/fields';
import '@pnp/sp/views';
// Files and folders
import '@pnp/sp/folders';
import '@pnp/sp/files';
// Users and groups
import '@pnp/sp/site-users';
import '@pnp/sp/site-groups';
import '@pnp/sp/profiles';
// Search
import '@pnp/sp/search';
// Taxonomy
import '@pnp/sp/taxonomy';
// Content types
import '@pnp/sp/content-types';
🗄️ Adding Caching
PnPjs v3 has a built-in Caching behaviour. Add it when creating the sp instance:
import { spfi, SPFx } from '@pnp/sp';
import { Caching } from '@pnp/queryable';
this._sp = spfi().using(
SPFx(this.context),
Caching({ store: 'session' }) // 'session' | 'local'
);
With Caching applied, PnPjs stores GET responses in sessionStorage (or localStorage) using the request URL as the key. Identical requests within the same session return the cached value without hitting the network.
Cache behaviour details:
- Default TTL is 5 minutes for
sessionstore - Only GET requests are cached — POST/PATCH/DELETE always hit the network
- Cache key is the full request URL including
$select,$filter,$expand
For fine-grained control, apply caching per-call rather than globally:
// Cache this specific call for 10 minutes
const items = await sp.web.lists
.getByTitle('Announcements')
.items
.using(Caching({ store: 'session', expireFunc: () => pnpAddMinutes(new Date(), 10) }))();
🪵 Adding Logging
In development, enable the console logging observer to see every HTTP request PnPjs makes:
import { ConsoleListener, Logger, LogLevel } from '@pnp/logging';
import { PnPLogging } from '@pnp/queryable';
this._sp = spfi().using(
SPFx(this.context),
PnPLogging(LogLevel.Verbose)
);
Logger.subscribe(ConsoleListener());
Logger.activeLogLevel = LogLevel.Verbose;
This logs the full URL, method, status code, and timing for every request — invaluable for diagnosing unexpected queries, missing $select fields, or cache misses.
Disable verbose logging in production by gating it behind the environment config:
if (this.context.pageContext.site.serverRelativeUrl.includes('/dev')) {
Logger.activeLogLevel = LogLevel.Verbose;
} else {
Logger.activeLogLevel = LogLevel.Warning;
}
🧱 Building a Reusable Service Layer
Do not scatter sp.web.lists.getByTitle(...) calls directly in React components. Centralise all data access in service classes that receive the sp instance via constructor injection.
src/services/ListService.ts
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
export interface IAnnouncementItem {
Id: number;
Title: string;
Body: string;
Expires: string | null;
}
export class ListService {
private readonly _sp: SPFI;
constructor(sp: SPFI) {
this._sp = sp;
}
public async getAnnouncements(): Promise<IAnnouncementItem[]> {
return this._sp.web.lists
.getByTitle('Announcements')
.items
.select('Id', 'Title', 'Body', 'Expires')
.filter(`Expires ge '${new Date().toISOString()}' or Expires eq null`)
.orderBy('Created', false)
.top(10)();
}
public async addAnnouncement(title: string, body: string): Promise<void> {
await this._sp.web.lists
.getByTitle('Announcements')
.items
.add({ Title: title, Body: body });
}
public async deleteAnnouncement(id: number): Promise<void> {
await this._sp.web.lists
.getByTitle('Announcements')
.items
.getById(id)
.delete();
}
}
Consuming in a React component:
import * as React from 'react';
import { SPFI } from '@pnp/sp';
import { ListService, IAnnouncementItem } from '../../services/ListService';
interface IAnnouncementsProps {
sp: SPFI;
}
const Announcements: React.FC<IAnnouncementsProps> = ({ sp }) => {
const [items, setItems] = React.useState<IAnnouncementItem[]>([]);
React.useEffect(() => {
const svc = new ListService(sp);
svc.getAnnouncements().then(setItems);
}, []);
return (
<ul>
{items.map(item => (
<li key={item.Id}>
<strong>{item.Title}</strong>
<p>{item.Body}</p>
</li>
))}
</ul>
);
};
export default Announcements;
🔐 Cross-Site Calls
By default, spfi().using(SPFx(context)) targets the current site. To call a different site, override the base URL:
import { spfi, SPFx } from '@pnp/sp';
import { InjectHeaders } from '@pnp/queryable';
const crossSiteSp = spfi('https://contoso.sharepoint.com/sites/hr').using(
SPFx(this.context)
);
const hrItems = await crossSiteSp.web.lists
.getByTitle('HRPolicies')
.items
.select('Title', 'DocumentUrl')();
The SPFx behaviour injects the correct auth token regardless of the target site URL — as long as the signed-in user has access.
🚀 Deploy
npm run build
npm run bundle -- --ship
npm run package-solution -- --ship
📂 GitHub Source
The companion repository contains a complete SPFx solution with the full service layer pattern — sp, graph, caching, logging, and cross-site calls all wired up and ready to extend.
View full SPFx project on GitHub:PnPjs v3 SPFx service layer — sp, graph, caching, and context injection wired up
✅ Summary
- Install
@pnp/spand optionally@pnp/graph— the SPFx behaviour is bundled inside@pnp/sp. - Create
spfi().using(SPFx(this.context))inonInit— never at module level or inside components. - Import sub-modules (
@pnp/sp/lists,@pnp/sp/items, etc.) as side effects — missing imports cause silent runtime failures. - Add
Cachingbehaviour tospfi()for automatic response caching keyed by request URL. - Enable
PnPLoggingin development to see every HTTP request in the browser console. - Centralise all data access in constructor-injected service classes — keep React components free of direct
spcalls. - For cross-site calls, pass the target URL as the first argument to
spfi().
Happy coding!
Author
Ravichandran@Hi_Ravichandran
