Show Spinner in Salesforce LWC Headless Actions

Show Spinner in Salesforce LWC Headless Actions

How to Show a Spinner in Salesforce LWC Headless Actions (lightning__RecordAction)

When implementing custom business logic in Salesforce Lightning Experience, developers frequently use Lightning Web Component (LWC) Quick Actions. One powerful approach is using a headless action (where actionType is configured as Action inside the lightning__RecordAction target). Unlike standard screen actions, headless actions run JavaScript code instantly without opening a default UI modal container.

While headless actions provide a seamless UX for fast processing, long-running asynchronous processes—such as complex Apex calls or data manipulations via uiRecordApi—can leave users in the dark. Without immediate visual feedback, users may click the action button multiple times, causing race conditions or duplicate transactions.

To solve this UX hurdle, you can utilize lightning/modal programmatically to build a clean, blocking processing spinner. In this post, we will walk through the implementation of a reusable spinner modal component designed specifically for headless operations.

The Solution Architecture

To overlay a loading indicator on top of the record page during a headless action execution, we split the logic into two components:

  1. Spinner Modal Component: A programmatic modal that encloses a lightning-spinner and relies on an internal promise to self-terminate once processing completes.
  2. Account Update Component: The target headless action component that triggers the data transaction and handles the instantiation and lifecycle control of our spinner modal.

1. The Spinner Modal Component

This component acts as the user interface layer that blocks screen interaction while showing a customizable message.

HTML (spinnerModal.html)

<template>
    <lightning-modal-body>
        <div class="slds-is-relative" style="height: 80px;">
            <lightning-spinner 
                alternative-text="Processing" 
                size="medium">
            </lightning-spinner>
        </div>
        <p class="slds-text-align_center slds-m-top_medium">
            {message}
        </p>
    </lightning-modal-body>

    <lightning-modal-footer>
        <lightning-button
            label="Cancel"
            onclick={handleClose}
            disabled={disableClose}>
        </lightning-button>
    </lightning-modal-footer>
</template>

JavaScript (spinnerModal.js)

import { api } from 'lwc';
import LightningModal from 'lightning/modal';

export default class SpinnerModal extends LightningModal {
    @api message;
    @api workDone;

    /**
     * Component lifecycle hook.
     * Waits for the passed promise to resolve, unlocks the closure, and then automatically closes the modal.
     */
    async connectedCallback() {
        const result = await this.workDone;
        this.disableClose = false;
        this.close(result);
    }   

    /**
     * Event handler for the manual 'Cancel' button click.
     */
    handleClose() {
        this.disableClose = false;
        this.close();
    }   
}

Configuration (spinnerModal.js-meta.xml)

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>

2. The Account Update Headless Action Component

This is the component registered to your Quick Action. It triggers the programmatic modal and fires off an asynchronous update using Salesforce’s updateRecord wire adapter function.

HTML (accountUpdateAction.html)

<template>    
</template>

JavaScript (accountUpdateAction.js)

import { LightningElement, api } from 'lwc';
import { updateRecord } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import ID_FIELD from '@salesforce/schema/Account.Id';
import DESCRIPTION_FIELD from '@salesforce/schema/Account.Description';
import SpinnerModal from 'c/spinnerModal';

export default class AccountUpdateAction extends LightningElement {
    @api recordId;

    /**
     * Invoked automatically when the Headless Action runs.
     * Coordinates launching the async process spinner and updating the Account record.
     */
    @api invoke() {
        let closeModal;
        // Constructing a callback reference via a Promise to control the modal closure externally
        const workDone = new Promise(resolve => closeModal = resolve);

        // Open the spinner modal to block UI while processing
        SpinnerModal.open({
            size: 'small',
            disableClose: true,      
            label: 'Updating Account',
            message: 'Processing, please wait...',
            workDone
        });

        // Mapping field attributes for mapping UI Record API payload
        const fields = {};
        fields[ID_FIELD.fieldApiName] = this.recordId;
        fields[DESCRIPTION_FIELD.fieldApiName] = 'Test Update ' + new Date().toISOString();

        // Perform asynchronous record patch
        updateRecord({ fields })
            .then(() => {
                closeModal(); // Notifies the modal to self-close
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Success',
                        message: 'Account updated',
                        variant: 'success'
                    })
                );
            })  
            .catch(error => {   
                closeModal(); // Safely close the modal on failures
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Error updating or reloading record',
                        message: error.body ? error.body.message : error.message,
                        variant: 'error'
                    })
                );
            }); 
    }  
}

Configuration (accountUpdateAction.js-meta.xml)

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordAction</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordAction">
            <actionType>Action</actionType>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Leave a Reply