Understanding the PnP SPFx Progress Control — Deep Dive: Classes, Interfaces, and React.FC
A companion piece to the “Progress from Scratch” series. This article breaks down the WP3 code in detail and explores a broader question: how do you take advantage of PnP’s class-based patterns — interfaces, inheritance, base classes — when your Web Parts are written as function components (React.FC) instead of class components?
Introduction
Throughout this series, one recurring tension has come up: the official PnP documentation frequently shows examples using classes (class BaseAction implements IProgressAction { ... }), while our Web Parts are built as function components. It’s easy to assume these two styles don’t mix, or that “React.FC” means “no classes anywhere in the file.” That assumption is wrong, and understanding why it’s wrong unlocks a much more reusable way of working with controls like Progress.
This article walks through the corrected WP3 code line by line, then zooms out to show how the same object-oriented ideas from the PnP documentation — interfaces, inheritance, shared base behavior — can be fully leveraged in a functional-component codebase, and even reused across multiple Web Parts.
The code, explained
interface IMockProgressAction extends IProgressAction { execute: () => Promise<void>;}
This line defines a new interface that inherits everything from IProgressAction (which, as established in Part 3, only guarantees a title) and adds a required execute method on top. This is TypeScript’s extends keyword doing for interfaces what it does for classes: building a more specific contract out of a more general one, without modifying the original.
const createMockAction = (title: string, delayInMilliseconds: number): IMockProgressAction => { return { title, execute: (): Promise<void> => { return new Promise<void>((resolve) => { setTimeout(() => resolve(), delayInMilliseconds); }); }, };};
This is a factory function: a plain function whose entire job is to construct and return an object of a given shape. Notice what it does not use: no class, no new, no this. The delayInMilliseconds parameter is captured by the execute arrow function through a JavaScript feature called a closure — the inner function “remembers” the value from the outer function’s scope, even after createMockAction has finished running. This closure is what replaces the role a private class field would normally play.
const mockActions: IMockProgressAction[] = [ createMockAction('Create folder', 1000), createMockAction('Copy files', 1500), createMockAction('Set permissions', 1000),];
Each call to createMockAction produces one independent object. They don’t share any state with each other — each has its own title and its own closed-over delayInMilliseconds. This is functionally equivalent to creating three instances of a class with new, just without the class syntax.
for (let index = 0; index < mockActions.length; index++) { setCurrentActionIndex(index); await mockActions[index].execute();}
This loop is the “runner.” Because IMockProgressAction guarantees execute exists and returns a Promise, TypeScript allows await mockActions[index].execute() directly, with no casting. The loop is intentionally written with a classic for and await inside it (rather than .forEach or .map) because forEach does not wait for promises — using it here would fire off every execute() call immediately, defeating the entire purpose of a sequential progress indicator.
The bigger idea: separating “what renders” from “what behaves”
The reason mixing classes and React.FC feels confusing at first is a category mistake: React.FC is a constraint on components, not on every piece of code in the file. A component is something that takes props and returns JSX. MockAction — or createMockAction‘s output — is not a component. It never gets rendered. It’s a plain data-plus-behavior object that the component uses as an input.
Once that distinction is clear, it becomes obvious that you can freely bring over any object-oriented pattern from the PnP documentation into a function-component project, as long as it stays on the “data and behavior” side of that line, never on the “rendering” side.
Deriving and reusing action types
The real payoff of treating actions as their own small object-oriented layer is reusability across Web Parts. Instead of writing one-off createMockAction calls in every component, you can build a small hierarchy of action types once, and reuse it everywhere.
Option A: class-based inheritance (closer to the official docs style)
// actions/BaseProgressAction.tsabstract class BaseProgressAction implements IMockProgressAction { public title: string; constructor(title: string) { this.title = title; } // Every concrete subclass must provide its own execute() implementation public abstract execute(): Promise<void>;}// actions/DelayAction.tsclass DelayAction extends BaseProgressAction { private delayInMilliseconds: number; constructor(title: string, delayInMilliseconds: number) { super(title); this.delayInMilliseconds = delayInMilliseconds; } public execute(): Promise<void> { return new Promise<void>((resolve) => { setTimeout(() => resolve(), this.delayInMilliseconds); }); }}// actions/ApiCallAction.tsclass ApiCallAction extends BaseProgressAction { private requestFn: () => Promise<void>; constructor(title: string, requestFn: () => Promise<void>) { super(title); this.requestFn = requestFn; } public execute(): Promise<void> { return this.requestFn(); }}
Here, BaseProgressAction centralizes the shared piece (holding a title) and declares the contract every action must follow (execute), while DelayAction and ApiCallAction each provide their own concrete behavior. This mirrors exactly the kind of structure the official PnP example hints at, just made explicit and reusable across as many Web Parts as you like.
Using it from a React.FC component requires nothing special:
const mockActions: IMockProgressAction[] = [ new DelayAction('Create folder', 1000), new DelayAction('Copy files', 1500), new ApiCallAction('Notify webhook', callNotificationApi),];
Option B: composition with factory functions (closer to WP3’s style)
If you’d rather avoid class entirely, the same hierarchy can be expressed with functions that build on each other:
// actions/createDelayAction.tsconst createDelayAction = (title: string, delayInMilliseconds: number): IMockProgressAction => ({ title, execute: () => new Promise<void>((resolve) => setTimeout(resolve, delayInMilliseconds)),});// actions/createApiCallAction.tsconst createApiCallAction = (title: string, requestFn: () => Promise<void>): IMockProgressAction => ({ title, execute: requestFn,});
There’s no formal “inheritance” here, but the effect is the same: a small, growing library of reusable action builders, all satisfying IMockProgressAction, that any Web Part can import and combine.
Which one to prefer
Neither is more “correct” — they trade off differently:
- Class-based inheritance shines when actions share a meaningful amount of internal logic beyond just the shape (e.g., shared retry logic, shared logging, shared error handling in a common base class).
- Factory functions with closures shine when actions are mostly independent and the goal is just to guarantee a consistent shape — which, for most mocked and even many real SPFx scenarios, is enough on its own.
Given this series’ overall function-component style, Option B is the more natural fit going forward, but it’s worth recognizing Option A as the direct, idiomatic translation of what the official documentation shows — useful to know when reading PnP’s own examples or other class-based SPFx codebases.
Practical example
Try extracting createMockAction from the WP3 component into its own file, src/webparts/progressControlWp/actions/createMockAction.ts, and importing it back into ProgressWp3.tsx. Nothing about its behavior changes — but this is the first step toward the pattern shown above: action definitions living independently from any single Web Part, ready to be reused in WP4, WP5, or entirely different projects.
Conclusion
React.FC restricts how a component can be written — it says nothing about the rest of a file, or a codebase. Interfaces, inheritance, and object-oriented base classes are still fully available tools for the “behavior” layer that a functional component consumes, whether that behavior is expressed through classes (as the official PnP documentation prefers) or through factory functions and closures (as this series has preferred). Recognizing that boundary is what makes it possible to borrow useful patterns from class-based documentation examples without needing to abandon a function-component architecture — and it’s what makes action definitions like these worth extracting into their own reusable, shared module.
Summary table
| Topic | Short Description | Official Documentation Links |
|---|---|---|
Interface extension (extends) | Building a more specific contract (IMockProgressAction) from a general one (IProgressAction) | https://www.typescriptlang.org/docs/handbook/2/objects.html#extending-types |
| Factory functions | Plain functions that construct and return objects, using closures instead of class fields to hold private data | https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures |
Class inheritance (extends, abstract) | Sharing common structure/behavior across concrete action types via a base class | https://www.typescriptlang.org/docs/handbook/2/classes.html#inheritance |
| Separating components from behavior objects | React.FC constrains components only; plain classes/objects used as data are unaffected by that constraint | https://react.dev/learn/your-first-component |
for...await vs forEach with promises | Why a classic for loop with await is required for true sequential execution | https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await…of |
Back in the main series: Part 4 explores the control’s display-related props — showOverallProgress, showIndeterminateOverallProgress, and hideNotStartedActions.
