Lightning Web Components Guide

A complete, leveled curriculum — fundamentals, data, patterns, architect track — with worked examples and the common mistake called out in every topic.

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:

VisualforceAuraLWC
Born asServer-rendered pagesClient framework (pre-standards era)Thin layer over web standards
RenderingServer round-trip per interactionProprietary component engineNative Custom Elements + Shadow DOM
LanguageApex + markupCustom syntax, pre-ES6 JSStandard ES modules, modern JavaScript
PerformanceSlowest (server renders)Heavier (framework overhead)Fastest (browser-native)
Today's roleLegacy pages, PDFsLegacy components; no new investmentAll 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)

StageSections, in sequenceYou can now…
Start HereThis page → ArchitectureSetupPlan & DesignExplain what LWC is and deploy a component
Level 1 · FundamentalsStructureTemplatesBindingProperties & ReactivityEventsLMSStylingBuild and compose components that talk to each other
Level 2 · DataLDS@wireApexrefreshApex & ErrorsGraphQLFormsDatatableFilesRead, write, refresh and present real org data safely
Level 3 · PatternsDynamicSlotsFlow ScreensNavigationDates & i18nPerformanceDebuggingBuild reusable, localized, fast components
Level 4 · ArchitectLifecycleLWSStateJestPackagingA11yBest PracticesCapstoneOwn 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
  • 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:false directives are deprecated — you'll still see them in legacy code and old tutorials, but write new code with lwc:if
  • List Rendering:
    • for:each={array} for:item="item" — the everyday loop (every row needs a key)
    • iterator:it={array} — when you need it.first / it.last flags for special styling
  • Data & Event Binding:
    • {property} - One-way data binding
    • onclick={handler} - Event binding (or dynamic listeners via lwc:on — see Modern LWC)
  • Element Access — lwc:ref:
    • Mark an element with lwc:ref="myDiv" and reach it in JS as this.refs.myDiv — cleaner than querySelector
<!-- 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
  • Reactive Properties:
    • Use @track decorator for private reactive properties
    • Use @api decorator for public properties
  • 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}
  • 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 on prefix (onclick, onchange)
  • Custom Events:
    • Created with CustomEvent constructor
    • Can include custom data payload
// 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:else to show/hide components
    • Simple for a few components with known types
  • <lwc:component> with lwc: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:dynamic directive is deprecated — migrate to lwc: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 loadScript and loadStyle
  • 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 Methods:
    • describe() - Groups related tests
    • it() - Defines individual test cases
    • expect() - Makes assertions
  • Testing Tools:
    • createElement() - Creates component instance
    • jest.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
  • 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 @wire and 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 error property of @wire results
    • 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/platformShowToastEvent instead of leaving users staring at a blank component

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 @api properties, 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=true for reads, refreshApex() after writes, and always handle the error branch
    • Prefer @wire (declarative, cached, reactive) over imperative calls unless you need explicit control of when the call happens
  • Modern Syntax:
    • lwc:if not if:true; lwc:component lwc:is not lwc:dynamic; this.refs not querySelector where possible — see Modern LWC Features
    • Set an explicit LWC apiVersion in .js-meta.xml so framework behavior is predictable across releases
  • Security:
    • Never build HTML strings from user input (innerHTML is 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
  • Accessibility:
    • Base components give you a11y for free — keep it: labels on every input, alternative-text on icons/spinners
    • Use semantic HTML (button for actions, not clickable divs) and test keyboard-only navigation
  • 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

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):

TargetWhere it appearsNotes
lightning__RecordPageRecord pagesGets @api recordId and objectApiName automatically
lightning__AppPage / lightning__HomePageApp and Home pagesGeneral dashboards/widgets
lightning__FlowScreenScreen flowsInputs/outputs wired to flow variables; newer releases added LWC local actions (client-side flow logic)
lightning__Tab / lightning__UtilityBarCustom tabs, utility barFull-page tools and always-on helpers
lightningCommunity__PageExperience Cloud sitesPublic-facing; check guest-user security carefully
Lightning Out 2.0External 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?

RequirementRight toolWhy
Simple record form / related list layoutStandard components + Dynamic FormsZero code, admin-configurable
Guided multi-step user inputScreen 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 polishLWCFull JavaScript control, testable, performant
Existing Aura component needs a small fixFix in place; plan migrationAura 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-form or lightning/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__RecordPage on Case — gets recordId free 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, fires search event) + c-contact-list (renders @api contacts, fires select) → 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, select event carries the right Id, error branch shows c-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 searchTerm and 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 executeMutation from 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?

NeedUse
Display/edit one record's fieldsLDS: lightning-record-form / uiRecordApi
Pick a related recordlightning-record-picker
Filtered/sorted lists, parent fields, paginationGraphQL module
Cross-object business logic, bulk DML, calloutsApex (@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 CSS display: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 key values (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 on window, 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>
  • apiVersion pins framework behavior — bump it deliberately to the latest version and retest.
  • isExposed + targets decide where admins can drop the component. Un-exposed components are still usable as children of your other components.
  • property elements become App Builder inputs wired to @api fields — 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 -- --coverage as 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 new data: 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

WhenHeadline LWC changes
Earlier updatesLightning 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 updateslwc: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 updatesState 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:falselwc: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/uiGraphQLApi importslightning/graphql in new code.
  • data: URI downloadsblob: object URLs (LWS now blocks the old way).
  • Hand-built record lookupslightning-record-picker.
  • Aura components → plan migration; the official guide's "Migrate Aura Components" chapter maps every Aura concept to its LWC equivalent.
  • Bump apiVersion per 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.

ComponentWhat it gives youReach for it when
lightning-record-formThe whole form from a layout or field list — view, edit, create modesStandard forms, fastest path; minimal markup
lightning-record-view-form + lightning-output-fieldRead-only display with full control of arrangementCustom read layouts, mixed with other markup
lightning-record-edit-form + lightning-input-fieldEditable fields you arrange yourself, with hooks for custom validation and submit logicCustom 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.
Common mistake: rebuilding forms from raw 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
        }
    }
}
  • type per column gives you locale-correct currency/date rendering for free; custom data types handle anything beyond the built-ins.
  • Inline editing: add editable: true to columns, handle onsave, pass draft values to updateRecord — 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".
Common mistake: mutating the data array in place (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(...)).

Sources: lightning-datatable (Component Reference)

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
}
Common mistake: passing 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
}
Common mistake: letting a raw exception reach the user. "System.QueryException: List has no rows for assignment" is confusing, unprofessional, and sometimes a security leak — every user-visible error should have been written by a person.

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 ShareType and Visibility. 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's Visibility can be AllUsers, InternalUsers or SharedUsers — 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.
Common mistake: testing uploads only as yourself (an admin who can see everything) and shipping. File-visibility bugs are invisible to admins by definition — always verify as the least-privileged persona who needs the file.

Sources: lightning-file-upload (Component Reference)

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>
Common mistake: hardcoding modal content and cloning the component for each variant. Three near-identical modals is the smell; slots are the cure.

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 LightningElement for behavior that needs this (toasting, error handling): components then extend ErrorHandlingElement instead of LightningElement. 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.

Common mistake: trying to share a whole component via inheritance. If it renders, share it by composition (slots, children); inheritance is only for behavior.

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.
Common mistake: forgetting the 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.

Sources: Use Components in Flow Screens (LWC Guide)

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-time does 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.
Common mistake: 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 });
}
Common mistake: hardcoding English strings in templates. The org adds a second language, Translation Workbench translates every Custom Label — and your component still says "Submit" because the string lives in your markup. Label everything user-visible from day one; retrofitting is misery.

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-input wires labels, lightning-button handles 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: label on inputs, alternative-text on 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.
Common mistake: building a custom dropdown, modal or combobox without keyboard support — the bug is invisible to mouse-using developers and absolute to keyboard-only users. If a base component version exists (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

DecisionChoice + whySection
Timezone displayFormatted components + relative dates — submissions cross regions; zero manual mathDates & i18n
File visibilityContentDocumentLink.Visibility = AllUsers set explicitly — portal users must see agent uploadsFiles
SecurityApex with WITH USER_MODE; guest access review; no field reaches the wire the profile can't seeBest Practices
NavigationNavigationMixin everywhere — the portal's base path differs from internal LightningNavigation
ErrorsreduceErrors + inline validation + toasts only for confirmationsErrors & Feedback
PerformanceTimeline paginated at 25 events; upload panel lazy-rendered behind a tabPerformance

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.
The meta-mistake this section exists to prevent: skipping architecture review until problems appear in production. One hour of tree-drawing and decision-recording is the cheapest debugging you'll ever do.