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
| Layer | Component | Responsibility |
|---|---|---|
| Flexipage | Visibility filter | Scope component to the right object/record type (one-time, at page load) |
| LWC JS | State machine | Determine current state (hidden / processing / broken) based on wire data + record age + timers |
| LWC HTML | Template | Render exactly one banner based on getter values |
| LWC CSS | Custom styles | Banner colors, CSS-only spinner, plain button — all visible in light DOM |
| Apex | Controller | Guard 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
| Choice | Why |
|---|---|
@wire(getRecord, ...) with Lightning Data Service | Reactive — re-fires automatically when getRecordNotifyChange is called |
hasInitialized flag | Prevents the grace period chain from re-starting on every wire re-fire |
| Record age check before starting timers | Old 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 getters | Template bindings are simple; exactly one banner renders at a time |
clearTimeout in disconnectedCallback | Prevents 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 Container Styles
.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
| Principle | Why |
|---|---|
@AuraEnabled (not cacheable=true) | This is an action, not a query — caching would be wrong |
| Guard clauses validate state | Prevent duplicate or invalid operations before hitting the queueable |
AuraHandledException with user-friendly messages | The LWC surfaces these via toast events — end users read them |
| Delegate to existing queueable | Do 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:
- A record page that users land on
- Background automation that runs after user action
- A field that indicates whether the automation has completed
- A recovery action users can take if automation fails
Example Scenarios
| Scenario | Field to Watch | Recovery Action |
|---|---|---|
| Opportunity approval status after submission | ApprovalStatus__c | Re-submit approval |
| Order acknowledgment from external system | Order_Acknowledged__c | Retry acknowledgment call |
| Contract activation after signature | Status = "Activated" | Manually trigger activation |
| Invoice generation after billing run | Invoice__c populated | Re-run billing |
| Case auto-assignment after create | OwnerId != creator | Manual reassignment |
What to Change for Each Use Case
- Field constants at the top of the JS — swap
IS_SYNCINGandCREATED_DATEfor your target fields isSyncedgetter logic — change the condition that means "done/OK"- Banner text — update the processing and warning messages
- Apex controller — call a different queueable or invocable
- Flexipage object scope — update the meta.xml
<object>tag - 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:
- 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.
- CSS-only visual elements — bypasses shadow DOM restrictions on
lightning-spinnerandlightning-buttonthat 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.