Understanding the PnP SPFx Progress Control — Part 3: Real Actions with execute()

“Progress from Scratch” series: in this article (3 of 5) we replace the manual “Next step” button from Part 2 with actions that implement a real execute() method, matching the pattern officially recommended by the PnP documentation. By the end, currentActionIndex will advance automatically as each mocked asynchronous action completes.

Introduction

In Part 2, currentActionIndex became real React state, but it was still moved by a human clicking a button. That was a useful stepping stone, but it doesn’t reflect how this control is meant to be used in practice: a sequence of actions that runs on its own, updating the UI as each step actually finishes its work.

This is where the IProgressAction interface earns its full potential. So far, our mocked actions were plain objects with just a title. In this part, we turn each action into an object that also knows how to execute itself, through an execute() method that returns a Promise. This is exactly the pattern shown in the official PnP documentation, where a base class implements IProgressAction and exposes an execute method for each concrete action to fill in.

The conceptual shift

Part 2Part 3
What an action “is”{ title: string }An object with title and behavior (execute())
What advances currentActionIndexA button clickThe completion of an asynchronous operation
Who decides “this step is done”The developer, manuallyThe Promise returned by execute() resolving

The control itself still doesn’t change at all — it still just reads actions and currentActionIndex and renders accordingly, exactly as established in Part 1. What changes is that now there’s a real “runner” function orchestrating execution and updating state as work actually completes, instead of a human doing it by hand.

The code

import * as React from 'react';
import { useState } from 'react';
import { Progress, IProgressAction } from '@pnp/spfx-controls-react/lib/Progress';
import { PrimaryButton } from '@fluentui/react';
// A class implementing IProgressAction, following the pattern recommended by the PnP docs.
// Note: this class lives OUTSIDE the functional component — it's a plain TypeScript
// class, not a React component, so using "class"/"private" here is perfectly valid.
class MockAction implements IProgressAction {
private actionTitle: string;
private delayInMilliseconds: number;
constructor(title: string, delayInMilliseconds: number) {
this.actionTitle = title;
this.delayInMilliseconds = delayInMilliseconds;
}
public get title(): string {
return this.actionTitle;
}
// Mocks a real operation (e.g. an API call) using a simple delay
public execute(): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), this.delayInMilliseconds);
});
}
}
const ProgressControlWp: React.FC = () => {
const mockActions: IProgressAction[] = [
new MockAction('Create folder', 1000),
new MockAction('Copy files', 1500),
new MockAction('Set permissions', 1000),
];
const [currentActionIndex, setCurrentActionIndex] = useState<number>(0);
const [isRunning, setIsRunning] = useState<boolean>(false);
// Sequentially executes every mocked action, advancing the index as each one finishes
const runAllActions = async (): Promise<void> => {
setIsRunning(true);
for (let index = 0; index < mockActions.length; index++) {
setCurrentActionIndex(index);
await (mockActions[index] as MockAction).execute();
}
// Push the index past the last item so everything visually shows as completed
setCurrentActionIndex(mockActions.length);
setIsRunning(false);
};
const resetSteps = (): void => {
setCurrentActionIndex(0);
};
return (
<div>
<Progress
title={'Progress running real (mocked) actions'}
actions={mockActions}
currentActionIndex={currentActionIndex}
showOverallProgress={true}
hideNotStartedActions={false}
/>
<PrimaryButton text="Run" onClick={runAllActions} disabled={isRunning} style={{ marginRight: 8 }} />
<PrimaryButton text="Reset" onClick={resetSteps} disabled={isRunning} />
</div>
);
};
export default ProgressControlWp;

Breaking down the important decisions

Why MockAction is a class living outside the component. IProgressAction, as recommended by the official documentation, is meant to be implemented by a class exposing a title getter and an execute method. A React.FC function component can’t contain class/private/this syntax internally (as we saw back in the very first build error of this series), but there’s no conflict in declaring a separate, plain TypeScript class alongside the component — it’s not a React component, just a regular object with behavior.

Why execute() returns Promise<void>. This is what makes the action “awaitable.” Wrapping setTimeout in a Promise is a common way to mock an asynchronous delay in development, standing in for what would eventually be a real asynchronous call (a PnPjs request, a fetch, a batch operation, etc.) once this pattern moves from mocked data to a production scenario.

Why the runner is a for loop with await inside, instead of Promise.all. The actions here are meant to represent a sequential process — step 2 only starts after step 1 truly finishes. Using Promise.all would kick off every mocked delay at the same time, which would break the entire premise of the control (showing one action “in progress” at a time). The for...await pattern guarantees strict, one-at-a-time sequencing.

Why setCurrentActionIndex(mockActions.length) at the end. This deliberately reuses the “index past the end of the array” behavior explored as an experiment back in Part 1. Instead of treating it as an edge case to avoid, here it becomes a small feature: it’s the cleanest way to mark the entire sequence as fully completed once the loop is done.

Why isRunning disables the buttons. Without this guard, clicking “Run” a second time while a sequence is already executing would start a second, overlapping runner — both competing to update the same currentActionIndex state. Disabling the trigger while isRunning is true prevents that race condition entirely.

Practical example: what to observe while testing

  1. Click Run. The first action (“Create folder”) should show as in progress (🔄) for roughly one second, then flip to completed (✅) as the second action becomes active.
  2. Watch the full sequence run on its own, with no further clicks required — each action’s mocked delay (1000ms, 1500ms, 1000ms) should be clearly visible in how long it stays “in progress.”
  3. Once the last action finishes, all three items should show as completed, with no action left “in progress.”
  4. While the sequence is running, try clicking Run again — nothing should happen, since the button is disabled by isRunning.
  5. Click Reset and run the sequence again to confirm it behaves consistently on repeat executions.

Conclusion

This part completes the transition from “the developer manually tells the control what step it’s on” to “the control reflects the real, asynchronous progress of actual work.” The two building blocks from Parts 1 and 2 — the rendering rule based on currentActionIndex, and currentActionIndex as React state — didn’t need to change at all. All that was added was a proper implementation of IProgressAction with an execute() method, and a small orchestration function to run them in sequence. This is precisely the shape you’d carry over into a real Web Part, simply by swapping the mocked setTimeout inside execute() for genuine asynchronous logic. In Part 4, we shift focus away from execution and toward the control’s display options — showOverallProgress, showIndeterminateOverallProgress, and hideNotStartedActions — to understand how each one changes what the user actually sees.

Summary table

TopicShort DescriptionOfficial Documentation Links
IProgressAction (full implementation)Interface implemented via a class exposing a title getter and an execute() method that performs the actual workhttps://pnp.github.io/sp-dev-fx-controls-react/controls/Progress/
execute(): Promise<void>Contract that lets the control’s caller await an action’s completion before moving to the next onehttps://pnp.github.io/sp-dev-fx-controls-react/controls/Progress/
Sequential execution with for...awaitPattern ensuring only one action runs “in progress” at a time, as opposed to Promise.all running them concurrentlyhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await…of
Guarding against concurrent runsUsing a boolean state (isRunning) to disable the trigger button and prevent overlapping executionshttps://react.dev/learn/state-a-components-memory
Marking a sequence as fully completedSetting currentActionIndex past the last array index, reusing the overflow behavior from Part 1https://pnp.github.io/sp-dev-fx-controls-react/controls/Progress/

In Part 4, we’ll explore the control’s display-related props — showOverallProgress, showIndeterminateOverallProgress, and hideNotStartedActions — to see exactly how each one changes what’s rendered.

Edvaldo Guimrães Filho Avatar

Published by