LWC Overview
Lightning Web Components (LWC) is a modern JavaScript framework built on web standards that leverages custom elements, modules, shadow DOM, and other new language constructs available in ECMAScript 7 and beyond.
Key Features
- Standards-based: Built on modern web standards like Web Components, ES6+, and custom elements
- Lightweight: Faster performance compared to Aura components
- Two-way interoperability: Works with existing Aura components
- Progressive: Works in all modern browsers including mobile
- Secure: Uses shadow DOM for style and DOM encapsulation
// Simple LWC Component Example
import { LightningElement } from 'lwc';
export default class HelloWorld extends LightningElement {
greeting = 'World';
handleChange(event) {
this.greeting = event.target.value;
}
}
<!-- helloWorld.html -->
<template>
<div class="slds-p-around_medium">
<lightning-input
label="Name"
value={greeting}
onchange={handleChange}>
</lightning-input>
<p class="slds-m-top_medium">Hello, {greeting}!</p>
</div>
</template>
LWC vs Aura vs Visualforce
LWC is not "Aura with new syntax" — it's a different rendering model built on web standards the browser executes natively, instead of a framework abstraction layer:
| Visualforce | Aura | LWC | |
|---|---|---|---|
| Born as | Server-rendered pages | Client framework (pre-standards era) | Thin layer over web standards |
| Rendering | Server round-trip per interaction | Proprietary component engine | Native Custom Elements + Shadow DOM |
| Language | Apex + markup | Custom syntax, pre-ES6 JS | Standard ES modules, modern JavaScript |
| Performance | Slowest (server renders) | Heavier (framework overhead) | Fastest (browser-native) |
| Today's role | Legacy pages, PDFs | Legacy components; no new investment | All new UI work |
The same "Hello, Name" idea: Visualforce posts to the server to re-render; Aura declares an <aura:attribute> and handler in its own dialect; the LWC version above is just standard JavaScript with a decorator — which is why LWC skills transfer to any modern framework and back.
How to Learn This Guide (in order)
| Stage | Sections, in sequence | You can now… |
|---|---|---|
| Start Here | This page → Architecture → Setup → Plan & Design | Explain what LWC is and deploy a component |
| Level 1 · Fundamentals | Structure → Templates → Binding → Properties & Reactivity → Events → LMS → Styling | Build and compose components that talk to each other |
| Level 2 · Data | LDS → @wire → Apex → refreshApex & Errors → GraphQL → Forms → Datatable → Files | Read, write, refresh and present real org data safely |
| Level 3 · Patterns | Dynamic → Slots → Flow Screens → Navigation → Dates & i18n → Performance → Debugging | Build reusable, localized, fast components |
| Level 4 · Architect | Lifecycle → LWS → State → Jest → Packaging → A11y → Best Practices → Capstone | Own a component system end to end |
LWC Architecture
Lightning Web Components follow a modular architecture that leverages modern JavaScript and web standards while integrating seamlessly with the Salesforce platform.
Core Architectural Concepts
-
Custom Elements:
- LWC components extend the LightningElement base class
- Each component is a custom HTML element with its own tag name
-
Modules:
- JavaScript files use ES6 module syntax (import/export)
- CSS is scoped to the component by default
-
Shadow DOM:
- Provides style and DOM encapsulation
- Prevents CSS and JavaScript from leaking out
-
Lightning Web Runtime:
- Optimized rendering engine for LWC
- Handles component lifecycle and reactivity
Setup & Development Tools
To develop Lightning Web Components, you'll need the right tools and development environment setup.
Required Tools
-
Visual Studio Code:
- Primary IDE for LWC development
- Lightweight and extensible
-
Salesforce Extensions Pack:
- Provides syntax highlighting, code completion, and deployment tools
- Includes CLI integration
-
Salesforce CLI (
sf):- Command-line backbone for Salesforce development — the old
sfdx force:*command style is retired - Used for creating, previewing, testing, and deploying components
- Command-line backbone for Salesforce development — the old
-
Live Preview (VS Code extension, formerly "Local Dev"):
- Renders your component in real time as you type — single-component hot updates without deploying
-
Code Builder / StackBlitz:
- Code Builder is VS Code in the browser (full Salesforce tooling, zero install)
- StackBlitz runs open-source LWC online — great for practicing pure component logic, but it can't use
@salesforce/*imports, wire adapters, or base components
Setup Steps
# Install Salesforce CLI (or download the installer from developer.salesforce.com/tools/salesforcecli)
npm install -g @salesforce/cli
# Create a new project and open it
sf project generate --name my-lwc-project
code my-lwc-project
# Authenticate with your org
sf org login web --alias DevOrg --set-default
# Create a new LWC component
sf lightning generate component --type lwc --name myComponent --output-dir force-app/main/default/lwc
# Deploy it
sf project deploy start --source-dir force-app
The end-to-end deployment workflow (test levels, CI/CD, production rules) is identical to Apex — see the Apex guide's Build & Deploy section. This guide's Build & Deploy section covers what's LWC-specific.
Component Structure
A Lightning Web Component consists of several files that work together to define its functionality, appearance, and behavior.
Required Files
-
HTML Template (.html):
- Defines the component's structure and markup
- Uses the
<template>tag
-
JavaScript Controller (.js):
- Contains the component's logic and properties
- Must extend
LightningElement
-
Configuration File (.js-meta.xml):
- Defines metadata for the component
- Specifies targets, supported versions, etc.
Optional Files
- CSS Styles (.css): Component-specific styles
- SVG Icon (.svg): Custom icon for the component
- Test Files (.test.js): Unit tests for the component
HTML Templates
The HTML template defines the structure and content of your Lightning Web Component using a combination of standard HTML and LWC-specific directives.
Template Directives
-
Conditional Rendering —
lwc:if/lwc:elseif/lwc:else:- The current, official syntax for conditional blocks
- The old
if:true/if:falsedirectives are deprecated — you'll still see them in legacy code and old tutorials, but write new code withlwc:if
-
List Rendering:
for:each={array} for:item="item"— the everyday loop (every row needs akey)iterator:it={array}— when you needit.first/it.lastflags for special styling
-
Data & Event Binding:
{property}- One-way data bindingonclick={handler}- Event binding (or dynamic listeners vialwc:on— see Modern LWC)
-
Element Access —
lwc:ref:- Mark an element with
lwc:ref="myDiv"and reach it in JS asthis.refs.myDiv— cleaner thanquerySelector
- Mark an element with
<!-- Example Template with Current Directives -->
<template>
<div class="container" lwc:ref="container">
<template lwc:if={isLoggedIn}>
<h1>Welcome, {userName}!</h1>
</template>
<template lwc:elseif={isLoading}>
<lightning-spinner alternative-text="Loading"></lightning-spinner>
</template>
<template lwc:else>
<h1>Please log in</h1>
</template>
<ul>
<template for:each={items} for:item="item">
<li key={item.id}>{item.name}</li>
</template>
</ul>
</div>
</template>
JavaScript Controllers
The JavaScript controller defines the behavior and logic of your Lightning Web Component.
Key Concepts
-
Class Definition:
- Must extend
LightningElement - Can include properties, methods, and lifecycle hooks
- Must extend
-
Reactive Properties:
- Use
@trackdecorator for private reactive properties - Use
@apidecorator for public properties
- Use
-
Methods:
- Define event handlers and business logic
- Can be private (no decorator) or public (
@api)
import { LightningElement, track, api } from 'lwc';
export default class MyComponent extends LightningElement {
@api title = 'Default Title'; // Public reactive property
@track items = []; // Private reactive property
// Public method
@api
addItem(item) {
this.items = [...this.items, item];
}
// Private method
handleClick() {
this.dispatchEvent(new CustomEvent('clicked'));
}
}
CSS Styling
Lightning Web Components support scoped CSS that's encapsulated to the component using shadow DOM.
Styling Features
-
Scoped Styles:
- CSS only applies to the component
- No style leakage to parent or child components
-
SLDS Support:
- Salesforce Lightning Design System classes available
- Recommended for consistent Salesforce look and feel
-
CSS Custom Properties:
- Support for CSS variables
- Can be used for theming
/* myComponent.css */
.container {
padding: 1rem;
background-color: var(--lwc-colorBackgroundAlt, #ffffff);
}
.title {
font-size: 1.5rem;
color: var(--lwc-brandAccessible, #0176d3);
}
/* Using SLDS classes */
.slds-button {
margin-top: 1rem;
}
Data Binding
Lightning Web Components support one-way data binding between JavaScript properties and HTML templates.
Binding Types
-
Property Binding:
- JavaScript to HTML:
{propertyName} - HTML attribute to JavaScript:
data-id={propertyName}
- JavaScript to HTML:
-
Getters:
- Computed properties based on other values
- Reactive when dependent properties change
import { LightningElement, track } from 'lwc';
export default class DataBindingExample extends LightningElement {
@track firstName = '';
@track lastName = '';
// Computed property
get fullName() {
return `${this.firstName} ${this.lastName}`.trim();
}
handleFirstNameChange(event) {
this.firstName = event.target.value;
}
handleLastNameChange(event) {
this.lastName = event.target.value;
}
}
<!-- dataBindingExample.html -->
<template>
<lightning-input
label="First Name"
value={firstName}
onchange={handleFirstNameChange}>
</lightning-input>
<lightning-input
label="Last Name"
value={lastName}
onchange={handleLastNameChange}>
</lightning-input>
<p>Full Name: {fullName}</p>
</template>
Event Handling
Events in LWC allow components to communicate with each other, following the DOM events standard.
Event Types
-
Standard DOM Events:
- click, change, input, etc.
- Handled with
onprefix (onclick, onchange)
-
Custom Events:
- Created with
CustomEventconstructor - Can include custom data payload
- Created with
// Dispatching a custom event
handleButtonClick() {
const event = new CustomEvent('buttonclick', {
detail: {
buttonId: 'submit',
timestamp: Date.now()
},
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
<!-- Handling events -->
<template>
<!-- Standard DOM event -->
<button onclick={handleClick}>Click Me</button>
<!-- Custom event from child component -->
<c-child-component onbuttonclick={handleButtonClick}></c-child-component>
</template>
Cross-Component Messaging: LMS & Pub-Sub
When components aren't in a parent-child relationship (siblings, different regions of a Lightning page, or even an LWC talking to Aura or Visualforce), events can't bubble between them. The official, supported solution is Lightning Message Service (LMS) — use it for anything new. The homegrown pub-sub module you'll find in older projects is a legacy pattern kept here so you can recognize and migrate it.
Lightning Message Service (Recommended)
Create a Message Channel (a small XML metadata file), then publish and subscribe through lightning/messageService:
<!-- force-app/main/default/messageChannels/RecordSelected.messageChannel-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
<masterLabel>RecordSelected</masterLabel>
<isExposed>true</isExposed>
<lightningMessageFields>
<fieldName>recordId</fieldName>
</lightningMessageFields>
</LightningMessageChannel>
// Publisher component
import { publish, MessageContext } from 'lightning/messageService';
import RECORD_SELECTED from '@salesforce/messageChannel/RecordSelected__c';
import { LightningElement, wire } from 'lwc';
export default class ContactTile extends LightningElement {
@wire(MessageContext) messageContext;
handleSelect() {
publish(this.messageContext, RECORD_SELECTED, { recordId: this.contactId });
}
}
// Subscriber component
import { subscribe, unsubscribe, MessageContext } from 'lightning/messageService';
import RECORD_SELECTED from '@salesforce/messageChannel/RecordSelected__c';
import { LightningElement, wire } from 'lwc';
export default class ContactDetail extends LightningElement {
@wire(MessageContext) messageContext;
subscription = null;
recordId;
connectedCallback() {
this.subscription = subscribe(this.messageContext, RECORD_SELECTED,
(message) => { this.recordId = message.recordId; });
}
disconnectedCallback() {
unsubscribe(this.subscription);
this.subscription = null;
}
}
LMS also crosses technology boundaries — the same channel works from Aura components and Visualforce pages, which makes it the migration bridge in mixed orgs. For state shared by many components, also look at the new State Managers.
Legacy Pattern: Homegrown Pub-Sub (Recognize & Migrate)
// pubsub.js - Utility module
const callbacks = {};
const register = (eventName, callback) => {
if (!callbacks[eventName]) {
callbacks[eventName] = new Set();
}
callbacks[eventName].add(callback);
};
const unregister = (eventName, callback) => {
if (callbacks[eventName]) {
callbacks[eventName].delete(callback);
}
};
const fire = (eventName, payload) => {
if (callbacks[eventName]) {
callbacks[eventName].forEach(callback => {
try {
callback(payload);
} catch (error) {
console.error(error);
}
});
}
};
export { register, unregister, fire };
// Publisher component
import { fire } from 'c/pubsub';
export default class Publisher extends LightningElement {
handlePublish() {
fire('message', { text: 'Hello from publisher!' });
}
}
// Subscriber component
import { LightningElement } from 'lwc';
import { register, unregister } from 'c/pubsub';
export default class Subscriber extends LightningElement {
connectedCallback() {
register('message', this.handleMessage.bind(this));
}
disconnectedCallback() {
unregister('message', this.handleMessage.bind(this));
}
handleMessage(message) {
console.log('Received:', message.text);
}
}
Wire Service
The Lightning Web Components wire service provides a reactive way to get data from Salesforce, either through Apex methods or Lightning Data Service.
Key Features
- Reactive: Automatically refreshes when parameters change
- Declarative: Decorate properties with
@wire - Efficient: Manages server calls and caching
import { LightningElement, wire } from 'lwc';
import getContacts from '@salesforce/apex/ContactController.getContacts';
export default class ContactList extends LightningElement {
@wire(getContacts)
contacts;
// Alternative with error handling
@wire(getContacts)
wiredContacts({ error, data }) {
if (data) {
this.contacts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.contacts = undefined;
}
}
}
// Apex controller
public with sharing class ContactController {
@AuraEnabled(cacheable=true)
public static List<Contact> getContacts() {
return [SELECT Id, Name, Email FROM Contact LIMIT 50];
}
}
Apex Methods
Lightning Web Components can call Apex methods either imperatively or via the wire service.
Imperative Apex Calls
import { LightningElement } from 'lwc';
import createAccount from '@salesforce/apex/AccountController.createAccount';
export default class AccountCreator extends LightningElement {
accountName = '';
handleNameChange(event) {
this.accountName = event.target.value;
}
handleSubmit() {
createAccount({ name: this.accountName })
.then(result => {
// Handle success
})
.catch(error => {
// Handle error
});
}
}
Apex Method Parameters
- Single Parameter: Pass primitive values directly
- Multiple Parameters: Pass an object with property names matching the Apex method parameters
- Complex Objects: Must be serializable to JSON
Lightning Data Service
Lightning Data Service (LDS) provides a declarative way to work with Salesforce data, including record loading, editing, and deletion.
LDS Wire Adapters
import { LightningElement, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
const FIELDS = [NAME_FIELD, INDUSTRY_FIELD];
export default class AccountViewer extends LightningElement {
recordId = '001XXXXXXXXXXXXXXX'; // Replace with actual ID
@wire(getRecord, { recordId: '$recordId', fields: FIELDS })
account;
get name() {
return getFieldValue(this.account.data, NAME_FIELD);
}
get industry() {
return getFieldValue(this.account.data, INDUSTRY_FIELD);
}
}
Lifecycle Hooks
Lifecycle hooks are callback methods that allow you to run code at specific stages of a component's existence. Understanding these hooks is crucial for proper component initialization, rendering, and cleanup.
Key Lifecycle Hooks
-
constructor()
- First hook called when component is created
- Initialize properties but avoid DOM operations
-
connectedCallback()
- Called when element is inserted into the DOM
- Ideal for initializing state or fetching data
-
renderedCallback()
- Called after every render of the component
- Use for DOM operations after rendering
-
disconnectedCallback()
- Called when element is removed from DOM
- Clean up event listeners or subscriptions
import { LightningElement } from 'lwc';
export default class LifecycleExample extends LightningElement {
constructor() {
super();
console.log('Constructor called');
}
connectedCallback() {
console.log('Connected to DOM');
}
renderedCallback() {
console.log('Component rendered');
}
disconnectedCallback() {
console.log('Removed from DOM');
}
}
Dynamic Components
Dynamic components allow you to conditionally render different components at runtime, creating more flexible and modular applications.
Implementation Approaches
-
Conditional Rendering:
- Use
lwc:if/lwc:elseif/lwc:elseto show/hide components - Simple for a few components with known types
- Use
-
<lwc:component>withlwc:is(the current standard):- Render a component whose type is decided at runtime from an imported constructor
- Combined with dynamic
import(), the component's code loads only when needed — smaller initial bundles - The older
lwc:dynamicdirective is deprecated — migrate tolwc:component lwc:is - Requires
<capability>lightning__dynamicComponent</capability>in the component's.js-meta.xml
<!-- Simple case: conditional rendering -->
<template>
<template lwc:if={showComponentA}>
<c-component-a></c-component-a>
</template>
<template lwc:else>
<c-component-b></c-component-b>
</template>
</template>
<!-- Truly dynamic: type decided at runtime -->
<template>
<lwc:component lwc:is={dynamicCtor}></lwc:component>
</template>
// Load the right component on demand — its code isn't
// downloaded until this runs (great for performance)
import { LightningElement, api } from 'lwc';
export default class DynamicLoader extends LightningElement {
dynamicCtor;
@api
async loadView(viewName) {
const module = viewName === 'chart'
? await import('c/chartView')
: await import('c/tableView');
this.dynamicCtor = module.default;
}
}
Third-Party Libraries
While LWC provides many built-in features, you can integrate third-party JavaScript libraries for additional functionality.
Integration Methods
-
Static Resources:
- Upload JS/CSS files as static resources
- Load using
loadScriptandloadStyle
-
ES6 Modules:
- Import modules directly if they support ES6
- Must be compatible with LWC's security model
-
NPM Packages:
- Bundle the library's built file into a static resource (there's no npm install at runtime on-platform)
- Must be compatible with Lightning Web Security (LWS) — the sandbox that replaced Locker Service. LWS Trusted Mode now allows approved third-party scripts
LWS note: LWS now blocks data: URLs on anchor hrefs. Libraries that trigger downloads that way need the blob: pattern instead — create a Blob, use URL.createObjectURL(blob), then URL.revokeObjectURL(url) after the click.
// Loading a third-party library
import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
import D3_JS from '@salesforce/resourceUrl/d3';
import D3_CSS from '@salesforce/resourceUrl/d3Styles';
export default class ChartComponent extends LightningElement {
d3Initialized = false;
renderedCallback() {
if (this.d3Initialized) return;
Promise.all([
loadScript(this, D3_JS),
loadStyle(this, D3_CSS)
]).then(() => {
this.d3Initialized = true;
this.initializeChart();
});
}
initializeChart() {
// Use D3.js to create chart
}
}
Unit Testing
LWC components are tested with Jest via the official @salesforce/sfdx-lwc-jest package — tests run locally in Node (fast, no org needed) and belong in your CI pipeline. Note: unlike Apex, LWC tests don't gate deployment with a coverage percentage — the discipline is on you and your pipeline.
One-Time Setup
# From your project root (adds jest config + npm scripts)
sf force lightning lwc test setup
# Run all tests / watch mode while developing
npm run test:unit
npm run test:unit:watch
# Coverage report
npm run test:unit:coverage
Testing Approach
-
Test Structure:
- Test files should be named
componentName.test.js - Located in the
__tests__folder
- Test files should be named
-
Test Methods:
describe()- Groups related testsit()- Defines individual test casesexpect()- Makes assertions
-
Testing Tools:
createElement()- Creates component instancejest.fn()- Creates mock functions
// Example test file
import { createElement } from 'lwc';
import MyComponent from 'c/myComponent';
describe('c-my-component', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
});
it('displays greeting', () => {
const element = createElement('c-my-component', {
is: MyComponent
});
document.body.appendChild(element);
const div = element.shadowRoot.querySelector('div');
expect(div.textContent).toBe('Hello, World!');
});
it('emits click event', () => {
const element = createElement('c-my-component', {
is: MyComponent
});
document.body.appendChild(element);
const handler = jest.fn();
element.addEventListener('click', handler);
const button = element.shadowRoot.querySelector('button');
button.click();
expect(handler).toHaveBeenCalled();
});
});
Debugging
Debugging LWC components requires a combination of browser tools and Salesforce-specific techniques.
Debugging Techniques
-
Browser DevTools:
- Console logging with
console.log() - Debugger statements (
debugger;) - Inspecting component DOM in Elements panel
- Console logging with
-
Salesforce Tools:
- LWC Debug Mode (Setup → Debug Mode → enable per user): un-minified framework code, readable stack traces, extra warnings. Turn it off when done — it slows the org for that user
- Error Console (new): non-fatal component errors now log quietly to a background console with full history instead of interrupting users with popups — check it before assuming "no error happened"
- Developer Console / debug logs for the Apex side of
@wireand imperative calls - Live Preview (VS Code): reproduce and iterate on a rendering bug with instant hot updates instead of redeploying each attempt
-
Error Handling:
- Try/catch for imperative calls; the
errorproperty of@wireresults errorCallback(error, stack)— LWC's error boundary hook: implement it on a container component to catch errors thrown by any child component and render a fallback UI- Show failures with
lightning/platformShowToastEventinstead of leaving users staring at a blank component
- Try/catch for imperative calls; the
Error Boundary Example
// Container component that catches ANY descendant's error
import { LightningElement } from 'lwc';
export default class SafeContainer extends LightningElement {
childError;
errorCallback(error, stack) {
this.childError = error.message; // render a friendly fallback
console.error('Child component failed:', error, stack);
}
}
// safeContainer.html
// <template>
// <template lwc:if={childError}>
// <c-error-panel message={childError}></c-error-panel>
// </template>
// <template lwc:else>
// <slot></slot>
// </template>
// </template>
// Debugging example
import { LightningElement } from 'lwc';
export default class DebugExample extends LightningElement {
connectedCallback() {
console.log('Component connected');
// debugger; // Uncomment to pause execution here
try {
// Potentially problematic code
} catch(error) {
console.error('Error in component:', error);
}
}
}
Best Practices
Following best practices ensures your LWC components are performant, maintainable, and scalable.
Development Best Practices
-
Component Design:
- Keep components small and focused (Single Responsibility Principle) — a page is a tree of dumb presentational components under a few smart container components
- Use composition (slots, child components) over giant multi-purpose components
- Reach for base components (
lightning-*) before building your own — they're accessible, SLDS-styled, and maintained by Salesforce - Communicate down with
@apiproperties, up with custom events, across with LMS — never let a child reach into a parent
-
Data Access:
- Prefer Lightning Data Service (
lightning-record-form,lightning/uiRecordApi) over custom Apex — no Apex, no tests to write, built-in caching and cache invalidation - When you do use Apex:
cacheable=truefor reads,refreshApex()after writes, and always handle theerrorbranch - Prefer
@wire(declarative, cached, reactive) over imperative calls unless you need explicit control of when the call happens
- Prefer Lightning Data Service (
-
Modern Syntax:
lwc:ifnotif:true;lwc:component lwc:isnotlwc:dynamic;this.refsnotquerySelectorwhere possible — see Modern LWC Features- Set an explicit LWC
apiVersionin.js-meta.xmlso framework behavior is predictable across releases
-
Security:
- Never build HTML strings from user input (
innerHTMLis blocked/distorted by LWS anyway) — bind data through the template - Enforce CRUD/FLS on the Apex side with
WITH USER_MODE— the UI hiding a field is not security (see the Apex guide's Security section) - Keep third-party libraries LWS-compatible and loaded via static resources
- Never build HTML strings from user input (
-
Accessibility:
- Base components give you a11y for free — keep it: labels on every input,
alternative-texton icons/spinners - Use semantic HTML (
buttonfor actions, not clickabledivs) and test keyboard-only navigation
- Base components give you a11y for free — keep it: labels on every input,
-
Testing:
- Jest tests colocated in
__tests__; test behavior (rendered output, dispatched events), not implementation details - Test both success and error branches of every wire/imperative call
- Jest tests colocated in
Code Organization
force-app/main/default/lwc/
├── contactSearchApp/ # smart container: owns data + state
│ ├── contactSearchApp.html
│ ├── contactSearchApp.js
│ ├── contactSearchApp.js-meta.xml
│ └── __tests__/
│ └── contactSearchApp.test.js
├── contactList/ # dumb child: @api contacts in, events out
├── contactTile/ # dumb grandchild
├── errorPanel/ # shared presentational component
└── utils/ # service module (no HTML) — shared pure JS
├── utils.js
└── utils.js-meta.xml
A service module (JS-only component) is the LWC equivalent of an Apex utility class — shared formatting, validation, or API-shaping logic imported as import { formatName } from 'c/utils';.
Plan & Design: Before You Build a Component
The lifecycle for UI work mirrors Apex: Plan → Develop (locally, with Live Preview) → Build & Deploy → Test (Jest) → Debug → Release → Monitor. Planning a component well means answering three questions first: where will it run, should it even be an LWC, and how will it get its data?
Question 1 — Where Will It Run? (Targets)
A component declares where it can be used in its .js-meta.xml. Each target changes what's available to you (e.g., recordId only exists on record pages):
| Target | Where it appears | Notes |
|---|---|---|
lightning__RecordPage | Record pages | Gets @api recordId and objectApiName automatically |
lightning__AppPage / lightning__HomePage | App and Home pages | General dashboards/widgets |
lightning__FlowScreen | Screen flows | Inputs/outputs wired to flow variables; newer releases added LWC local actions (client-side flow logic) |
lightning__Tab / lightning__UtilityBar | Custom tabs, utility bar | Full-page tools and always-on helpers |
lightningCommunity__Page | Experience Cloud sites | Public-facing; check guest-user security carefully |
| Lightning Out 2.0 | External apps (any website) | A recent rework: embed LWCs outside Salesforce with OAuth 2.0 + UI Bridge API |
Question 2 — Should It Be an LWC at All?
| Requirement | Right tool | Why |
|---|---|---|
| Simple record form / related list layout | Standard components + Dynamic Forms | Zero code, admin-configurable |
| Guided multi-step user input | Screen Flow (embed LWCs for the hard screens) | Admins own the sequence; you own only the custom bits |
| Custom interactive UI, complex client logic, external UX polish | LWC | Full JavaScript control, testable, performant |
| Existing Aura component needs a small fix | Fix in place; plan migration | Aura still runs but gets no new features — new work should be LWC (see the official "Migrate Aura Components" guide chapter) |
Question 3 — How Will It Get Data?
- Single record + fields: Lightning Data Service (
lightning-record-formorlightning/uiRecordApi) — no Apex, cached, auto-refreshes across components. Always the first choice. - Lists, joins, aggregates with client-driven shape: the GraphQL API module — one round trip, exactly the fields you ask for.
- Complex server logic, DML with business rules: Apex methods (wire for reads, imperative for actions).
- Cross-component shared state: parent owns it, children receive via
@api; unrelated components use LMS or new State Managers.
Worked Example: Planning a "Contact Quick Search" Widget
- Requirement: support agents need to search contacts and preview details without leaving the case record page.
- Target:
lightning__RecordPageon Case — getsrecordIdfree for context. - Tool check: needs debounced search + custom preview layout → LWC (a standard related list can't do it).
- Component tree:
c-contact-search-app(container: owns state, calls data) →c-search-box(input, firessearchevent) +c-contact-list(renders@api contacts, firesselect) →c-contact-tile. - Data: GraphQL query (contacts filtered by name, 10 rows) — no Apex class to maintain or test.
- Design system: base components (
lightning-input,lightning-card) + SLDS utility classes → accessible and theme-correct with no custom CSS. - Test plan: Jest — renders empty state, renders results, debounce fires one query for fast typing,
selectevent carries the right Id, error branch showsc-error-panel.
Sources: LWC Developer Guide — Get Started · LWC Developer Guide
GraphQL & Modern Data Access
Two newer tools have reshaped how LWCs read Salesforce data: the GraphQL API module (query exactly the shape you need — lists, filters, parent fields — in one request) and the lightning-record-picker base component (a production-quality record lookup in one tag). Both remove whole categories of Apex controllers.
GraphQL Wire Adapter
import { LightningElement, wire } from 'lwc';
// The newer lightning/graphql module supersedes lightning/uiGraphQLApi
// (same adapter concept; old module still works in existing code)
import { gql, graphql } from 'lightning/graphql';
export default class AccountFinder extends LightningElement {
searchTerm = 'Acme';
@wire(graphql, {
query: gql`
query accountsByName($search: String) {
uiapi {
query {
Account(
where: { Name: { like: $search } }
first: 10
orderBy: { Name: { order: ASC } }
) {
edges {
node {
Id
Name { value }
Industry { value }
}
}
}
}
}
}`,
variables: '$graphqlVariables'
})
accountsResult;
get graphqlVariables() {
return { search: this.searchTerm + '%' };
}
get accounts() {
return this.accountsResult.data
? this.accountsResult.data.uiapi.query.Account.edges.map(e => e.node)
: [];
}
}
- Reactive: change
searchTermand the wire re-queries automatically (getter-based variables). - Respects security: GraphQL runs through UI API — FLS and sharing are enforced for the running user, no extra work.
- Writes too: GraphQL mutations are now GA — create/update/delete records imperatively via
executeMutationfrom the same module.
lightning-record-picker
<template>
<lightning-record-picker
label="Assign to Contact"
placeholder="Search Contacts..."
object-api-name="Contact"
onchange={handleRecordChange}>
</lightning-record-picker>
</template>
handleRecordChange(event) {
this.selectedContactId = event.detail.recordId; // null when cleared
}
Type-ahead search, recent records, keyboard navigation and accessibility — all built in. Before this component existed, teams hand-built lookup comboboxes with custom Apex search; ours took a few hundred lines (see the searchable picklist tutorial for the concept). Use the base component for record lookups going forward.
Which Data Tool When?
| Need | Use |
|---|---|
| Display/edit one record's fields | LDS: lightning-record-form / uiRecordApi |
| Pick a related record | lightning-record-picker |
| Filtered/sorted lists, parent fields, pagination | GraphQL module |
| Cross-object business logic, bulk DML, callouts | Apex (@AuraEnabled) |
Sources: LWC Developer Guide · Developer release guides (official blog)
Improve Performance
Slow Lightning pages are almost never "Salesforce being slow" — they're component design: too many server round trips, rendering thousands of rows, loading code the user never touches. Here's the optimization playbook, in the order you should apply it.
1. Eliminate Server Round Trips
- Cache reads:
@AuraEnabled(cacheable=true)lets the framework serve repeat calls from the client cache; LDS wire adapters cache and share record data across every component on the page automatically. - One shaped query beats three generic ones: GraphQL (or a single Apex method returning a wrapper) instead of separate wires for list + parent + count.
- Debounce user input so a search box fires one request, not one per keystroke:
handleSearchInput(event) {
window.clearTimeout(this.delayTimeout);
const value = event.target.value;
this.delayTimeout = window.setTimeout(() => {
this.searchTerm = value; // wire reacts once, 300ms after typing stops
}, 300);
}
2. Render Less
- Conditional rendering (
lwc:if) truly removes DOM — prefer it over CSSdisplay:none, which still pays full render cost for hidden content. - Paginate or virtualize long lists: render 25–50 rows, not 2,000. The new
lightning-dynamic-list-container(developer preview) offers — true virtualization that renders only viewport rows from up to 5,000 items, with keyboard/screen-reader support built in. - Stable
keyvalues (record Ids, never array index) let the diffing engine reuse DOM instead of rebuilding it.
3. Load Less Code Up Front
// The chart library + chart component download ONLY if the user opens the tab
async handleChartTabActive() {
if (!this.chartCtor) {
const module = await import('c/revenueChart'); // dynamic import
this.chartCtor = module.default; // <lwc:component lwc:is={chartCtor}>
}
}
- Heavy static resources (charting, PDF libs) belong behind user intent — load them in an event handler, not
connectedCallback. - Fewer components per Lightning page region; every component is a separate boot cost. Composition is good inside your tree — 15 sibling components dropped on one page is not.
4. Respect the Render Cycle
renderedCallback()runs after every re-render — guard one-time work with a boolean flag, and never mutate reactive state inside it unguarded (infinite render loop).- Do data shaping in getters (computed on demand) rather than duplicating state; keep
@track/reactive objects small. - Clean up in
disconnectedCallback: intervals, listeners onwindow, LMS subscriptions — leaks degrade long sessions (console apps especially).
5. Measure Before and After
- Chrome DevTools → Performance tab with LWC Debug Mode off (debug mode is intentionally slower — don't profile with it on).
- Network tab: count XHRs on page load; each wire/imperative call shows up — your target is "as few as the UX allows."
- Lightning App Builder page analysis flags heavy components on a page before your users do.
Sources: LWC Developer Guide (Improve Performance) · Developer release guide (official blog)
Build & Deploy: Shipping Components
LWCs are metadata like everything else — they deploy through the same pipeline as your Apex (see the Apex guide's Build & Deploy section for test levels, CI/CD and release strategy). Here's what's specific to components.
The .js-meta.xml Controls Everything
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>XX.0</apiVersion> <!-- your org's current API version -->
<isExposed>true</isExposed> <!-- false = invisible to App Builder -->
<masterLabel>Contact Quick Search</masterLabel>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__RecordPage">
<objects>
<object>Case</object> <!-- only offered on Case pages -->
</objects>
<property name="maxResults" type="Integer" default="10"
label="Max search results"/> <!-- admin-editable in App Builder -->
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
apiVersionpins framework behavior — bump it deliberately to the latest version and retest.isExposed+targetsdecide where admins can drop the component. Un-exposed components are still usable as children of your other components.propertyelements become App Builder inputs wired to@apifields — design for admin configurability instead of hardcoding.
Deploy, Preview, Iterate
# Deploy just the component you're working on
sf project deploy start --source-dir force-app/main/default/lwc/contactSearchApp
# Live Preview while developing (hot updates, no redeploy loop)
# → install the "Salesforce Live Preview" VS Code extension and press its
# preview button, or run the component in a scratch org with local dev enabled
# Run Jest before you push — LWC tests run locally, not in the org
npm run test:unit
CI Notes for Components
- Add
npm run test:unit -- --coverageas a pipeline step next to your Apex test run — the platform won't enforce LWC coverage for you. - Lint with the official ESLint config (
@salesforce/eslint-config-lwc, included in CLI-generated projects) — it now also catches Lightning Web Security violations like the newdata:URI block rule. - Components package cleanly into unlocked packages with their Apex — one versioned unit.
Sources: LWC Developer Guide · Salesforce CLI
Modern LWC Features You Should Be Using
LWC has evolved fast while many online tutorials stood still. Everything here is current syntax.
Directives: lwc:if, lwc:ref, lwc:spread, lwc:on
<template>
<!-- lwc:if / elseif / else: modern conditionals (if:true is deprecated) -->
<template lwc:if={isLoading}><lightning-spinner></lightning-spinner></template>
<template lwc:else>
<!-- lwc:ref: grab elements without querySelector → this.refs.searchBox -->
<lightning-input lwc:ref="searchBox" label="Search"></lightning-input>
<!-- lwc:spread: pass a whole object of properties at once -->
<c-contact-tile lwc:spread={tileProps}></c-contact-tile>
<!-- lwc:on (GA): attach event listeners from a JS object,
so WHICH events a component handles can be decided at runtime -->
<c-plugin-panel lwc:on={pluginHandlers}></c-plugin-panel>
</template>
</template>
// The JS side of lwc:spread and lwc:on
tileProps = { firstName: 'Ada', lastName: 'Lovelace', showAvatar: true };
pluginHandlers = {
save: (event) => this.handleSave(event),
cancel: (event) => this.handleCancel(event)
};
Complex Template Expressions (Beta)
Historically every tiny computation needed a getter. Template expressions put simple logic inline (enable the beta before relying on it in production):
<!-- Before: a getter in JS for every one of these -->
<p>{contact.FirstName ?? 'Unknown'}</p>
<p>{items.length > 0 ? 'Results' : 'No results'}</p>
<lightning-badge label={status.toUpperCase()}></lightning-badge>
TypeScript Support (GA)
# Type definitions for the platform + every base component
npm install --save-dev @salesforce/lightning-types
Write components in TypeScript with IntelliSense for lightning-* component APIs and platform modules — typos and wrong-type props surface in the editor instead of at runtime.
State Managers (GA)
The new @lwc/state package gives you reactive state that lives outside any component — shared, isolated, and unit-testable without rendering anything:
// c/cartState — a state manager module
import { defineState } from '@lwc/state';
export const cartState = defineState((atom, computed) => () => {
const items = atom([]);
const itemCount = computed({ items }, ({ items }) => items.length);
const addItem = (item) => items.set([...items.value, item]);
return { items, itemCount, addItem };
});
Any component can consume it and re-renders when it changes — the sanctioned answer to "many components share evolving state," where before teams bent LMS or wire adapters into shape. Built-in Lightning state managers also wrap LDS access to records, object info, and related lists.
Drive Agentforce from Your Component
import { open, execute } from 'lightning/accApi';
async handleAskAgent() {
await open(); // opens the Agentforce panel
await execute('Summarize this case for handoff'); // sends the utterance
}
Pairs naturally with everything in our Agentforce guide — your UI can now hand work to an agent mid-flow.
Sources: Developer release guides (official blog) · LWC Developer Guide
What's New in LWC
The verified timeline of recent LWC platform changes, and the checklist for modernizing older components.
Timeline of Recent LWC Changes
| When | Headline LWC changes |
|---|---|
| Earlier updates | Lightning Out 2.0 — embed LWCs in external apps (OAuth 2.0 + UI Bridge API) · new lightning/graphql module supersedes lightning/uiGraphQLApi · LWC local actions in Screen Flows (client-side, blank template) · LWS Trusted Mode for third-party scripts · SLDS 2.0 GA (dark mode beta on Starter) |
| Recent updates | lwc:on dynamic event listeners GA · GraphQL mutations GA (executeMutation) · TypeScript support GA (@salesforce/lightning-types) · standard__flow PageReference — launch any flow from LWC code · Error Console (non-fatal errors logged, not popped up) · Lightning Out 2.0 domain/namespace improvements · Beta: Complex Template Expressions, lightning-empty-state & lightning-illustration |
| Latest updates | State Managers GA (@lwc/state: atoms, computed values, LDS-backed managers) · native <details name> exclusive accordions · LWS blocks data: URIs on anchors (use blob:) + new ESLint rules · lightning/accApi — drive the Agentforce panel from components · Developer preview: lightning-dynamic-list-container virtualization (5,000 items) · Live Preview VS Code extension + faster HMR |
Modernization Checklist for Older Components
if:true/if:false→lwc:if/lwc:elseif/lwc:else(old directives are deprecated).lwc:dynamic→<lwc:component lwc:is={ctor}>.- Homegrown pub-sub modules → Lightning Message Service (or State Managers for shared state).
lightning/uiGraphQLApiimports →lightning/graphqlin new code.data:URI downloads →blob:object URLs (LWS now blocks the old way).- Hand-built record lookups →
lightning-record-picker. - Aura components → plan migration; the official guide's "Migrate Aura Components" chapter maps every Aura concept to its LWC equivalent.
- Bump
apiVersionper component deliberately and re-run Jest — behavior changes ride on the component's own version, so nothing breaks until you opt in.
Sources: Developer release guides (official blog) · LWC Guide Release Notes
Forms: The Three lightning-record-* Components
Before you hand-build any form, know that three base components already handle layout, field-level security, validation, and saving — with zero Apex. Choosing between them is a one-table decision.
| Component | What it gives you | Reach for it when |
|---|---|---|
lightning-record-form | The whole form from a layout or field list — view, edit, create modes | Standard forms, fastest path; minimal markup |
lightning-record-view-form + lightning-output-field | Read-only display with full control of arrangement | Custom read layouts, mixed with other markup |
lightning-record-edit-form + lightning-input-field | Editable fields you arrange yourself, with hooks for custom validation and submit logic | Custom UX around standard saving |
Example: Case Quick-Create with Custom Validation
<template>
<lightning-record-edit-form object-api-name="Case" onsubmit={handleSubmit} onsuccess={handleSuccess}>
<lightning-messages></lightning-messages>
<lightning-input-field field-name="Subject"></lightning-input-field>
<lightning-input-field field-name="Priority"></lightning-input-field>
<lightning-input-field field-name="Description"></lightning-input-field>
<lightning-button type="submit" variant="brand" label="Create Case"></lightning-button>
</lightning-record-edit-form>
</template>
handleSubmit(event) {
event.preventDefault(); // pause the save
const fields = event.detail.fields;
// Custom rule 1: high priority requires a description
if (fields.Priority === 'High' && !fields.Description) {
this.errorMessage = 'High-priority cases need a description.';
return;
}
// Custom rule 2: normalize the subject
fields.Subject = fields.Subject?.trim();
this.template.querySelector('lightning-record-edit-form').submit(fields);
}
handleSuccess(event) {
this.createdCaseId = event.detail.id; // navigate or toast from here
}
- Field-level security is respected automatically — users never see fields they can't access, and you wrote no security code.
- Validation rules from the org still run on submit; your client-side checks are additive UX, not the security boundary.
lightning-input fields plus imperative Apex. You inherit FLS handling, validation display, layout and saving logic that lightning-record-edit-form already does — and you'll maintain it forever. Hand-build only when the saving behavior itself must be custom.Sources: Work with Records Using Base Components (LWC Guide)
lightning-datatable: Collections Done Right
The workhorse for tabular data: sorting, inline edit, row actions, typed cells — all configuration, not code. The skill is in the columns definition and in treating data immutably.
Example: Opportunities with a Computed Column and Row Action
const COLUMNS = [
{ label: 'Name', fieldName: 'Name' },
{ label: 'Amount', fieldName: 'Amount', type: 'currency' },
{ label: 'Close Date', fieldName: 'CloseDate', type: 'date-local' },
{ label: 'Days to Close', fieldName: 'daysToClose', type: 'number',
cellAttributes: { class: { fieldName: 'urgencyClass' } } },
{ type: 'action', typeAttributes: { rowActions: [{ label: 'Mark Won', name: 'mark_won' }] } }
];
export default class OppTable extends LightningElement {
columns = COLUMNS;
rows = [];
@wire(getOpenOpportunities)
wiredOpps({ data, error }) {
if (data) {
// Computed column: build NEW row objects (immutability matters)
this.rows = data.map(opp => ({
...opp,
daysToClose: Math.ceil((new Date(opp.CloseDate) - Date.now()) / 86400000),
urgencyClass: new Date(opp.CloseDate) - Date.now() < 7 * 86400000
? 'slds-text-color_error' : ''
}));
}
}
handleRowAction(event) {
if (event.detail.action.name === 'mark_won') {
this.markWon(event.detail.row.Id); // updateRecord + refreshApex
}
}
}
typeper column gives you locale-correct currency/date rendering for free; custom data types handle anything beyond the built-ins.- Inline editing: add
editable: trueto columns, handleonsave, pass draft values toupdateRecord— see refreshApex for the after-save refresh. - Thousands of rows? Paginate or virtualize — the Performance section covers why "the data fits" isn't the same as "the DOM copes".
this.rows[3].Amount = 500) and wondering why nothing re-renders. The datatable re-renders when the reference changes — always build a new array (this.rows = this.rows.map(...)).refreshApex, Error Handling & User Feedback
Three small skills separate polished components from frustrating ones: refreshing stale wired data, converting raw errors into human messages, and choosing the right feedback channel.
refreshApex: The Cache-Invalidation Handshake
LDS keeps records in sync automatically — but data from @wire(apexMethod) is cached separately. After you change what the Apex result depends on, tell the wire to refetch:
import { refreshApex } from '@salesforce/apex';
wiredContactsResult; // hold the WHOLE wired result
@wire(getContacts, { accountId: '$recordId' })
wiredContacts(result) {
this.wiredContactsResult = result; // store it before destructuring
if (result.data) this.contacts = result.data;
}
async handleSave() {
await updateRecord({ fields }); // LDS write
await refreshApex(this.wiredContactsResult); // now the Apex wire refetches
}
this.wiredContactsResult.data to refreshApex. It needs the entire wired result object (data + error + internal bookkeeping) — the .data alone can't be refreshed.From Apex Exception to Friendly Message
// Apex: throw messages you'd show a human
throw new AuraHandledException('A contact with this email already exists.');
// LWC: a reduceErrors-style utility flattens every error shape
// (wire errors, imperative errors, arrays of page errors) into strings
function reduceErrors(errors) {
return (Array.isArray(errors) ? errors : [errors])
.filter(e => !!e)
.map(e => e.body?.message || e.body?.pageErrors?.[0]?.message
|| e.message || 'Unknown error')
.filter(m => !!m);
}
try {
await createContact({ record });
} catch (e) {
this.errorText = reduceErrors(e).join(', '); // inline, next to the form
}
Toast vs Inline: A One-Rule Decision
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
this.dispatchEvent(new ShowToastEvent({
title: 'Case created',
message: 'Case {0} is ready.',
messageData: [{ url: caseUrl, label: this.caseNumber }], // link inside the toast
variant: 'success'
}));
- Toast = transient confirmation of something that happened (saved, sent, deleted) — success, error, warning, info variants.
- Inline = anything the user must act on: validation problems belong next to the field, not in a toast that vanishes mid-read.
Sources: Handle Server Errors (LWC Guide) · Display Toast Notifications (LWC Guide)
Working with Files: Upload, Link, Visibility
Files look simple until sharing gets involved. Uploading is one component; understanding who can see the file afterward is the part that generates production tickets.
The Data Model in Thirty Seconds
- ContentDocument — the file itself (latest version pointer).
- ContentVersion — each uploaded version's actual blob and metadata.
- ContentDocumentLink — the join between a file and a record (or user/library), carrying
ShareTypeandVisibility. This object decides who sees what.
Example: Case Attachments — Upload + List
<template>
<lightning-file-upload
label="Attach supporting documents"
record-id={recordId}
accept={acceptedFormats}
onuploadfinished={handleUploadFinished}
multiple>
</lightning-file-upload>
<ul>
<template for:each={files} for:item="f">
<li key={f.contentDocumentId}>
<a href={f.downloadUrl}>{f.title}</a>
</li>
</template>
</ul>
</template>
acceptedFormats = ['.pdf', '.png', '.jpg'];
handleUploadFinished(event) {
// Each uploaded file arrives with its new documentId — the component
// already created the ContentVersion AND the ContentDocumentLink to recordId
const uploaded = event.detail.files; // [{ name, documentId }]
this.loadFileList(); // re-query the list (Apex or wire)
}
// Apex side of the list (bulk-safe, user mode):
// SELECT ContentDocumentId, ContentDocument.Title
// FROM ContentDocumentLink WHERE LinkedEntityId = :caseId WITH USER_MODE
The Visibility Trap
- A
ContentDocumentLink'sVisibilitycan beAllUsers,InternalUsersorSharedUsers— and defaults are conservative. A file one user uploads is not automatically visible to portal users or even all internal users on the same record. - Experience Cloud is where this bites hardest: internal agents see the attachment, the customer sees nothing (or vice versa). Set
Visibility = 'AllUsers'deliberately when portal users must see uploads. - Download URLs differ by context too — generate them per environment rather than hardcoding
/sfc/servlet.shepherd/...paths.
Slots, Composition & Shared Logic
The difference between ten components and a component system is composition: containers that accept content (slots), wrappers that standardize design, and shared logic that lives in exactly one place.
Slots: Parents Inject Markup into Children
<!-- c-modal-dialog: one modal shell for every modal you'll ever need -->
<template>
<section class="slds-modal slds-fade-in-open">
<header class="slds-modal__header">
<slot name="header"><h2>Default title</h2></slot>
</header>
<div class="slds-modal__content">
<slot></slot> <!-- default slot: the body -->
</div>
<footer class="slds-modal__footer">
<slot name="footer"></slot>
</footer>
</section>
</template>
<!-- A parent reuses it with completely different content: -->
<c-modal-dialog>
<span slot="header">Confirm deletion</span>
<p>This removes the record permanently.</p>
<lightning-button slot="footer" variant="destructive" label="Delete" onclick={confirmDelete}></lightning-button>
</c-modal-dialog>
Shared Logic Without Component Inheritance
LWC components don't inherit from each other the way Aura components extended one another. You share behavior two ways:
- Service modules (a JS-only component):
import { reduceErrors } from 'c/utils'— the default choice for pure functions. - A base class extending
LightningElementfor behavior that needsthis(toasting, error handling): components thenextend ErrorHandlingElementinstead ofLightningElement. The base class is not itself a component — no template, never rendered.
// c/errorHandlingElement — base class, no .html file needed
import { LightningElement } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { reduceErrors } from 'c/utils';
export default class ErrorHandlingElement extends LightningElement {
handleError(error, title = 'Something went wrong') {
this.dispatchEvent(new ShowToastEvent({
title, message: reduceErrors(error).join(', '), variant: 'error'
}));
}
}
// Any component: import and extend
export default class CasePanel extends ErrorHandlingElement { ... }
Wrap SLDS Once: Your Mini Design System
If the same SLDS markup block appears in ten components, wrap it once with @api variant/size props (c-my-badge variant="success"). One visual source of truth; a rebrand becomes a one-file change. The Best Practices section's container/presentational split pairs naturally with this.
Sources: Pass Markup into Slots (LWC Guide)
LWC in Flow: Screen Components
Embedding an LWC in a Screen Flow is the highest-leverage move in the admin-developer partnership: you build the hard screen once, admins compose entire processes around it without you.
The Contract: @api Properties ↔ Flow Variables
<!-- js-meta.xml: expose to Flow and declare the interface -->
<targets>
<target>lightning__FlowScreen</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__FlowScreen">
<property name="street" type="String" label="Street" role="outputOnly"/>
<property name="city" type="String" label="City" role="outputOnly"/>
<property name="postalCode" type="String" label="Postal Code" role="outputOnly"/>
<property name="countryDefault" type="String" label="Default Country" role="inputOnly"/>
</targetConfig>
</targetConfigs>
import { LightningElement, api } from 'lwc';
import { FlowAttributeChangeEvent } from 'lightning/flowSupport';
export default class AddressAutocomplete extends LightningElement {
@api street; @api city; @api postalCode; @api countryDefault;
handleAddressSelected(addr) {
this.street = addr.street;
this.city = addr.city;
this.postalCode = addr.postalCode;
// Tell Flow the output variables changed:
this.dispatchEvent(new FlowAttributeChangeEvent('street', this.street));
this.dispatchEvent(new FlowAttributeChangeEvent('city', this.city));
this.dispatchEvent(new FlowAttributeChangeEvent('postalCode', this.postalCode));
}
}
- In Flow Builder, the component appears as a screen element; each declared property maps to flow variables (input, output, or both via
role). - Validate before the user leaves the screen by implementing
@api validate()— return{ isValid, errorMessage }and Flow blocks Next until it passes. - Newer local actions extend the same idea beyond screens: client-side logic Flow can call directly (a component with a blank template).
- The Flow side of this partnership — when to build the whole thing declaratively — is the Flow guide's home turf.
targetConfig property declarations (or the role), then wondering why the flow designer shows no inputs/outputs. The meta.xml is the interface — no declaration, no variable mapping.Dates, Timezones, Locale & Custom Labels
Timezone bugs are the classic "works on my machine" of the Salesforce world: the server stores UTC, the client renders local, and any manual math in between silently drifts. The platform gives you tools that make the correct path also the easy one.
Render Time — Don't Compute It by Hand
<!-- Locale- and timezone-correct, automatically -->
<lightning-formatted-date-time
value={submittedDate}
year="numeric" month="short" day="2-digit"
hour="2-digit" minute="2-digit"
time-zone-name="short">
</lightning-formatted-date-time>
<lightning-formatted-number value={amount} format-style="currency"></lightning-formatted-number>
<lightning-formatted-time value={slotTime}></lightning-formatted-time>
- Datetime fields arrive from the server as UTC ISO strings; the formatted components convert to the user's configured timezone — not the browser's guess, the org profile setting.
- For relative display ("2 hours ago"),
lightning-relative-date-timedoes it correctly across zones. - When you must compute (business rules like "within 24 hours of submission"), do the math in UTC milliseconds (
Date.now() - new Date(iso).getTime()) and never parse a datetime string with manual offset arithmetic.
new Date() math assuming the server and client share a timezone. It works in the demo (developer and org both in one zone), then a user in another region files a bug about timestamps being hours off. This exact class of bug reaches production constantly — treat any manual offset arithmetic in review as guilty until proven innocent.Locale Info and Translatable Text
// Org/user locale facts, importable:
import LOCALE from '@salesforce/i18n/locale';
import CURRENCY from '@salesforce/i18n/currency';
import TIMEZONE from '@salesforce/i18n/timeZone';
// Translatable strings come from Custom Labels, never hardcoded:
import greeting from '@salesforce/label/c.Portal_Greeting';
import submitLabel from '@salesforce/label/c.Submit_Button';
export default class PricePanel extends LightningElement {
labels = { greeting, submitLabel };
formatter = new Intl.NumberFormat(LOCALE, { style: 'currency', currency: CURRENCY });
}
Sources: Internationalization (LWC Guide) · lightning-formatted-date-time (Reference)
Accessibility: Building for Everyone
Accessibility isn't a compliance checkbox — it's whether a keyboard-only user, a screen-reader user, or someone on a bad projector can use what you built. Base components carry most of the load; custom interactive components are where gaps appear.
The 80% You Get Free — and How to Keep It
- Base components ship accessible:
lightning-inputwires labels,lightning-buttonhandles keyboard and focus states, datatable manages grid navigation. Every base component you use is an a11y decision already made correctly. - Keep the semantics: a
<button>is focusable, keyboard-activatable and announced properly; a clickable<div onclick>is none of those. Semantic HTML first, ARIA only to fill genuine gaps. - Always label:
labelon inputs,alternative-texton icons/spinners/images — the linter flags most misses if you let it.
The Hard Part: Focus Management in Dynamic UI
// When a modal opens, focus must MOVE INTO it — and return on close
renderedCallback() {
if (this.isOpen && !this.hasFocused) {
this.hasFocused = true;
this.refs.closeButton.focus(); // first focusable element
}
}
handleKeydown(event) {
if (event.key === 'Escape') this.close(); // keyboard users can always exit
// Trap Tab within the modal while open (loop last → first)
}
close() {
this.isOpen = false;
this.hasFocused = false;
this.triggerElement?.focus(); // return focus to what opened it
}
- After any dynamic update that changes context (panel swap, results loaded, error appeared), ask: where is focus now, and did the screen reader learn what changed?
aria-live="polite"regions announce async results. - Test the cheap way first: unplug the mouse and complete every task with Tab/Enter/Escape/arrows. Ten minutes finds most failures an audit would.
lightning-combobox…), the a11y work is the strongest reason to use it.Sources: Accessibility (LWC Guide) · SLDS Accessibility (official)
Capstone: An Architecture Review, End to End
Everything in this guide, applied once, in order. The feature: a customer claims portal on Experience Cloud — status tracking, document upload, and a guided intake form. Anonymized from real project work; every decision links back to the section that teaches it.
1. The Component Tree (drawn before any code)
c-claims-portal-app ← container: owns state, wires data
├── c-claim-status-header ← dumb: @api claim, renders stage path
├── c-claim-timeline ← dumb: @api events[], relative dates
├── c-claim-documents ← upload + list (files section patterns)
│ └── lightning-file-upload
├── c-claim-intake ← lightning-record-edit-form + custom validation
└── c-modal-dialog ← shared slot-based shell (reused 3×)
- State ownership is explicit: the container owns the claim record and file list; children receive via
@api, request changes via events. No two components own the same truth (composition, best practices). - Data strategy: claim record via LDS (no Apex, cached); timeline via one wired Apex method returning a shaped DTO; document list re-queried after each upload with refreshApex.
2. The Decisions That Mattered
| Decision | Choice + why | Section |
|---|---|---|
| Timezone display | Formatted components + relative dates — submissions cross regions; zero manual math | Dates & i18n |
| File visibility | ContentDocumentLink.Visibility = AllUsers set explicitly — portal users must see agent uploads | Files |
| Security | Apex with WITH USER_MODE; guest access review; no field reaches the wire the profile can't see | Best Practices |
| Navigation | NavigationMixin everywhere — the portal's base path differs from internal Lightning | Navigation |
| Errors | reduceErrors + inline validation + toasts only for confirmations | Errors & Feedback |
| Performance | Timeline paginated at 25 events; upload panel lazy-rendered behind a tab | Performance |
3. Testing Strategy
- Jest per component: render states (loading/data/error), event contracts, wire mocks — the error branches got the most tests because that's where the real bugs were (Unit Testing).
- Persona passes: every flow executed as a guest user, a customer, and an agent — which is exactly how the file-visibility issue was caught pre-release rather than post.
- Keyboard-only pass on the intake form and modals (Accessibility).
4. What Reviewing Up Front Bought
- The state-ownership diagram killed two "who updates this?" bugs before they were written.
- Choosing base components first meant the intake form inherited FLS handling and a11y — two audit findings that never happened.
- The one custom rebuild (timeline) was justified in writing first — the decision record answered "why not a datatable?" six months later in one link.