Skip to main content

Dynamic State-Driven Banners in Lightning Web Components

Overview

Salesforce record pages often have background automation running after a user action — flow submissions, post-commit queueables, integration callouts. Users land on the target record page before that automation completes, see unfinished state, and panic. They click buttons they should not click yet, refresh the page unnecessarily, and report "bugs" that are actually transient states.

This guide walks through a state-driven banner pattern that solves this problem. The banner distinguishes between "still processing" and "actually broken," auto-refreshes the record during the processing window, provides a manual recovery button as a last-resort failsafe, and clears itself automatically when the state resolves. Along the way, you'll learn why lightning-spinner and lightning-button resist custom styling and how to replace them with plain HTML elements for full visual control.

  • What you'll learn: Three-state banner logic (hidden, processing, broken), reactive wire adapters with forced refresh via getRecordNotifyChange, record age as a discriminator for grace periods, timer cleanup patterns, CSS-only spinner animations, and the Apex controller pattern for manual recovery actions.
  • Who this is for: Salesforce developers, solutions architects, and technical consultants building LWC components that sit on record pages and need to react to background automation state without creating false alarms.

The Core Problem

The naive solution to post-automation UX problems is "show a warning banner if field X is false." But that creates false positives during the normal automation window. Users who land on a freshly-created record see the warning, click the recovery button, and generate duplicate work. Or they ignore the banner entirely and lose trust in the system.

A better solution is a state-aware banner that uses record age as the discriminator. A record created 5 seconds ago with field X = false is likely still being processed. A record created 10 minutes ago with the same state is actually broken. Treating these two cases the same is the root mistake.


Architecture

The pattern uses a three-state machine backed by a reactive wire adapter and two chained timers:

                    +-------------------+
| Page loads |
| Wire returns |
+---------+---------+
|
v
+-----------------------+
| Is field X = true? |
+--+----------------+---+
yes | | no
v v
+---------+ +-----------------------+
| HIDDEN | | Is record "fresh"? |
| (OK) | | (created < 2 min) |
+---------+ +--+----------------+---+
yes | | no
v v
+-----------------+ +-----------------+
| PROCESSING | | BROKEN |
| Blue banner | | Orange banner |
| + spinner | | + action btn |
| 30s timer | | |
+--------+--------+ +-----------------+
|
v
+----------------+
| getRecordNotify|
| Change --> wire|
| re-fires |
+----------------+

At any given render, exactly one of three states is active: hidden (everything is fine), processing (give automation time to finish), or broken (real problem, user needs to act).

Component Responsibilities

LayerComponentResponsibility
FlexipageVisibility filterScope component to the right object/record type (one-time, at page load)
LWC JSState machineDetermine current state (hidden / processing / broken) based on wire data + record age + timers
LWC HTMLTemplateRender exactly one banner based on getter values
LWC CSSCustom stylesBanner colors, CSS-only spinner, plain button — all visible in light DOM
ApexControllerGuard clauses plus delegation to existing queueable for manual recovery

The State Machine in JavaScript

The JavaScript logic has four parts: wire adapter, grace period starter, timer cleanup, and three mutually-exclusive getters.

import { LightningElement, api, wire, track } from 'lwc';
import { getRecord, getFieldValue, getRecordNotifyChange } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import IS_SYNCING from '@salesforce/schema/Quote.IsSyncing';
import CREATED_DATE from '@salesforce/schema/Quote.CreatedDate';
import syncQuote from '@salesforce/apex/QuoteNotSyncedBannerController.syncQuote';

const FRESH_QUOTE_WINDOW_MS = 120000; // 2 minutes
const INITIAL_GRACE_MS = 30000; // first refresh at 30s
const SECONDARY_GRACE_MS = 30000; // second refresh at 60s total

export default class QuoteNotSyncedBanner extends LightningElement {
@api recordId;
@track isLoading = false;
@track gracePeriodExpired = false;
hasInitialized = false;
quoteRecord;
initialTimer;
secondaryTimer;

@wire(getRecord, { recordId: '$recordId', fields: [IS_SYNCING, CREATED_DATE] })
wiredQuote(result) {
this.quoteRecord = result;
if (result.data && !this.hasInitialized) {
this.hasInitialized = true;
this.startGracePeriodIfFresh(result.data);
}
}

disconnectedCallback() {
this.clearTimers();
}

clearTimers() {
if (this.initialTimer) clearTimeout(this.initialTimer);
if (this.secondaryTimer) clearTimeout(this.secondaryTimer);
}

startGracePeriodIfFresh(data) {
const createdDate = getFieldValue(data, CREATED_DATE);
const isSyncing = getFieldValue(data, IS_SYNCING);

// Already good or missing data? Skip grace period entirely.
if (isSyncing === true || !createdDate) {
this.gracePeriodExpired = true;
return;
}

// Old record? Skip grace period, show warning immediately.
const ageMs = Date.now() - new Date(createdDate).getTime();
if (ageMs > FRESH_QUOTE_WINDOW_MS) {
this.gracePeriodExpired = true;
return;
}

// Fresh + not synced: start the chained timers
this.initialTimer = setTimeout(() => {
getRecordNotifyChange([{ recordId: this.recordId }]);
this.secondaryTimer = setTimeout(() => {
getRecordNotifyChange([{ recordId: this.recordId }]);
this.gracePeriodExpired = true;
}, SECONDARY_GRACE_MS);
}, INITIAL_GRACE_MS);
}

get isSynced() {
if (!this.quoteRecord || !this.quoteRecord.data) return false;
return getFieldValue(this.quoteRecord.data, IS_SYNCING) === true;
}

get isProcessing() {
if (!this.hasInitialized) return false;
if (this.isSynced) return false;
if (this.gracePeriodExpired) return false;
return true;
}

get isNotSynced() {
if (!this.hasInitialized) return false;
if (this.isSynced) return false;
return this.gracePeriodExpired;
}
}

Why These Design Choices Matter

ChoiceWhy
@wire(getRecord, ...) with Lightning Data ServiceReactive — re-fires automatically when getRecordNotifyChange is called
hasInitialized flagPrevents the grace period chain from re-starting on every wire re-fire
Record age check before starting timersOld records skip the grace period and show the warning immediately
Two chained timers (30s + 30s)First refresh catches fast automation; second refresh catches longer jobs; total 60s cap
Mutually-exclusive gettersTemplate bindings are simple; exactly one banner renders at a time
clearTimeout in disconnectedCallbackPrevents stray timer fires after navigation

The Two-Stage Grace Period

The pattern uses two chained timers rather than a single 60-second timer:

this.initialTimer = setTimeout(() => {
// After 30 seconds: refresh and check state
getRecordNotifyChange([{ recordId: this.recordId }]);

this.secondaryTimer = setTimeout(() => {
// After another 30 seconds: give up, show warning
getRecordNotifyChange([{ recordId: this.recordId }]);
this.gracePeriodExpired = true;
}, SECONDARY_GRACE_MS);
}, INITIAL_GRACE_MS);

The first 30-second check catches the common case where automation finishes in under 30 seconds. The second 30-second check handles larger transactions that take longer. After 60 seconds total, the processing state ends regardless and the warning banner appears.

Adjust the constants to match your automation timing. For a large Change Request with thousands of records, you might use 60 seconds + 60 seconds. For a simple record update, 10 seconds + 10 seconds might be enough.


The HTML Template

Two independent template if:true blocks. Because the getters are mutually exclusive, only one ever renders at a time.

<template>
<!-- Processing state: fresh, grace period active -->
<template if:true={isProcessing}>
<div class="banner banner-processing" role="status">
<div class="banner-inner">
<div class="css-spinner"></div>
<h2 class="banner-text">
This record is still processing. The screen will refresh automatically when ready. Please do not make any changes until this message disappears.
</h2>
</div>
</div>
</template>

<!-- Broken state: grace period expired, still not ready -->
<template if:true={isNotSynced}>
<div class="slds-notify slds-notify_alert slds-theme_warning banner banner-warning" role="alert">
<span class="slds-assistive-text">warning</span>
<div class="banner-inner">
<span class="slds-icon_container slds-m-right_x-small">
<lightning-icon icon-name="utility:warning" alternative-text="Warning" size="small" variant="inverse"></lightning-icon>
</span>
<h2 class="banner-text">
You must sync this record before proceeding. Click Sync Now to initiate syncing.
</h2>
<button type="button" class="sync-button" onclick={handleSync} disabled={isLoading}>
<template if:true={isLoading}>Syncing...</template>
<template if:false={isLoading}>Sync Now</template>
</button>
</div>
</div>
</template>
</template>

The Shadow DOM Battle: Why Custom CSS Beats Lightning Base Components

The single hardest lesson from building this pattern: lightning-spinner and lightning-button render inside shadow DOM that cannot be styled by parent CSS. The CSS custom properties that Salesforce documents for these components — --sds-c-button-brand-text-color, --slds-c-spinner-color-background, and others — are inconsistently honored across org versions and often get ignored outright.

If you need a spinner with a specific background color, you cannot reliably force lightning-spinner to comply. The inverse variant helps with the dot color but not the container backdrop, which renders as a pale semi-transparent rectangle.

The solution: skip those components entirely. Use plain HTML elements and style them directly with your own CSS.

CSS-Only Rotating Spinner

.css-spinner {
width: 1.25rem;
height: 1.25rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
flex-shrink: 0;
animation: css-spinner-rotate 0.9s linear infinite;
}

@keyframes css-spinner-rotate {
to {
transform: rotate(360deg);
}
}

A circle border with one corner colored differently, rotated via a CSS animation. No dependencies, no shadow DOM, fully stylable. Works everywhere.

Plain Button with Full Color Control

.sync-button {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #014486;
color: #ffffff !important;
font-weight: 700;
font-size: 14px;
border: 1px solid #014486;
border-radius: 0.25rem;
padding: 0.4rem 1rem;
cursor: pointer;
min-width: 6rem;
font-family: "Salesforce Sans", Arial, sans-serif;
transition: background-color 0.15s ease-in-out;
}

.sync-button:hover:not(:disabled) {
background-color: #032d60;
border-color: #032d60;
}

.sync-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

A plain <button type="button"> with your own classes. Full control over every pixel. Works on every org without caveats.

.banner {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border-radius: 0.25rem;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.16);
}

.banner-warning {
background-color: #fe9339;
color: #080707;
border: 1px solid #dd7a01;
}

.banner-processing {
background-color: #014486;
color: #ffffff;
border: 1px solid #032d60;
}

.banner-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}

Flexbox with gap handles all the spacing between icon/spinner, text, and button. No manual margins needed.


The Apex Controller for Manual Recovery

When the user clicks the recovery button, the goal is to trigger the same work that would normally run automatically. Keep the controller thin — it's just a gate that validates inputs and enqueues the existing queueable.

public with sharing class QuoteNotSyncedBannerController {

@AuraEnabled
public static void syncQuote(Id quoteId) {
if (quoteId == null) {
throw new AuraHandledException('Quote Id is required.');
}

Quote q = [SELECT Id, OpportunityId, IsSyncing FROM Quote WHERE Id = :quoteId LIMIT 1];

if (q.IsSyncing) {
throw new AuraHandledException('This record is already synced.');
}

if (q.OpportunityId == null) {
throw new AuraHandledException('This record has no Opportunity — cannot sync.');
}

System.enqueueJob(new PurchaseRequestQuoteSyncQueueable(new Set<Id>{ quoteId }));
}
}

Key Principles

PrincipleWhy
@AuraEnabled (not cacheable=true)This is an action, not a query — caching would be wrong
Guard clauses validate statePrevent duplicate or invalid operations before hitting the queueable
AuraHandledException with user-friendly messagesThe LWC surfaces these via toast events — end users read them
Delegate to existing queueableDo not duplicate sync logic; reuse the proven automation

The Button Handler with Toast Feedback

async handleSync() {
this.isLoading = true;
try {
await syncQuote({ quoteId: this.recordId });
this.dispatchEvent(new ShowToastEvent({
title: 'Sync initiated',
message: 'This record is being synced. The screen will update automatically.',
variant: 'success'
}));
setTimeout(() => {
getRecordNotifyChange([{ recordId: this.recordId }]);
}, 3000);
} catch (error) {
this.dispatchEvent(new ShowToastEvent({
title: 'Sync failed',
message: error.body && error.body.message ? error.body.message : 'An error occurred while syncing.',
variant: 'error'
}));
} finally {
this.isLoading = false;
}
}

Note the 3-second delay before getRecordNotifyChange. Queueables run asynchronously after the @AuraEnabled method returns, so we give the platform a moment to process before refreshing the wire. Without this delay, the refresh happens before the queueable has started, and the user sees the banner remain visible until the next natural refresh.


Flexipage vs LWC Responsibilities

When configuring this component in Lightning App Builder, the question comes up: should visibility conditions live on the flexipage or inside the LWC?

The answer is both, for different purposes.

Flexipage handles scope. Use flexipage filters for conditions that define "should this component even be considered for display on this page?" Examples: "Most Recent Version = true," "Record Type = X." These are evaluated once at page load and do not change during the user session.

LWC handles state. Everything that depends on field values that might change during the user session — the "Is this record processing, ok, or broken?" decision — belongs inside the component. Flexipage filters are not reactive. If you put "IsSyncing = false" as a flexipage filter, the component mounts once based on the initial value and will not hide itself when the wire refreshes after the automation completes.

For the banner pattern, use flexipage to scope to the right object and record type, then let the LWC handle everything else.


Gotchas Worth Highlighting

refreshApex Does Not Work for getRecord

refreshApex only works for custom @AuraEnabled(cacheable=true) wire adapters. When your component wires getRecord (Lightning Data Service), calling refreshApex does nothing. Use getRecordNotifyChange([{ recordId }]) instead — it invalidates the LDS cache for that specific record and causes wires to re-fire.

Always Clean Up Timers in disconnectedCallback

If the user navigates away while a setTimeout is pending, the callback will still fire and attempt to interact with a record that is no longer loaded. Tracking timer handles and clearing them on disconnect is simple hygiene that prevents hard-to-reproduce bugs.

Use a hasInitialized Flag to Prevent Repeated Grace Periods

The wire adapter can fire multiple times during a component's lifetime (initial load, cache updates, parameter changes). The grace period timer chain should only start once, on the first successful data load. A simple boolean flag prevents duplicate timer chains that would race each other.

Plain Text Button Labels Work for Loading State

No need for complex spinner-in-button logic:

<button onclick={handleSync} disabled={isLoading}>
<template if:true={isLoading}>Syncing...</template>
<template if:false={isLoading}>Sync Now</template>
</button>

Swap the text, disable the button, done.


Adapting This Pattern to Other Use Cases

The exact pattern applies anywhere you have:

  1. A record page that users land on
  2. Background automation that runs after user action
  3. A field that indicates whether the automation has completed
  4. A recovery action users can take if automation fails

Example Scenarios

ScenarioField to WatchRecovery Action
Opportunity approval status after submissionApprovalStatus__cRe-submit approval
Order acknowledgment from external systemOrder_Acknowledged__cRetry acknowledgment call
Contract activation after signatureStatus = "Activated"Manually trigger activation
Invoice generation after billing runInvoice__c populatedRe-run billing
Case auto-assignment after createOwnerId != creatorManual reassignment

What to Change for Each Use Case

  1. Field constants at the top of the JS — swap IS_SYNCING and CREATED_DATE for your target fields
  2. isSynced getter logic — change the condition that means "done/OK"
  3. Banner text — update the processing and warning messages
  4. Apex controller — call a different queueable or invocable
  5. Flexipage object scope — update the meta.xml <object> tag
  6. Grace period constants — adjust timing to match your automation

The core pattern — wire adapter plus grace period plus dual-state template plus CSS-only spinner plus plain button — stays the same.


Summary

Record page banners that "show when field is false" are too simplistic. Real users experience a gap between submitting a form and seeing automation finish, and during that gap a naive banner creates false alarms. The state-driven pattern in this article distinguishes between "still processing" and "actually broken" using record age as the discriminator, auto-refreshes the record during the grace period, and falls back to a warning banner with a manual recovery button if the automation truly fails.

The two critical design choices are:

  1. The grace period with record age check — gives automation time to finish on fresh records while showing the warning immediately on older records that are past the normal automation window.
  2. CSS-only visual elements — bypasses shadow DOM restrictions on lightning-spinner and lightning-button that otherwise prevent custom colors and backgrounds.

Together they produce a banner that feels polished, reacts correctly to state changes, and never lies to users about what is happening.