46 KiB
LWC Component Patterns
Comprehensive code examples for common Lightning Web Component patterns.
Table of Contents
- PICKLES Framework Details
- Wire Service Patterns
- GraphQL Patterns
- Modal Component Pattern
- Record Picker Pattern
- Workspace API Pattern
- Parent-Child Communication
- Sibling Communication (via Parent)
- Navigation Patterns
- TypeScript Patterns
- Apex Controller Patterns
PICKLES Framework Details
P - Prototype
Purpose: Validate ideas early before full implementation.
| Action | Description |
|---|---|
| Wireframe | Create high-level component sketches |
| Mock Data | Use sample data to test functionality |
| Stakeholder Review | Gather feedback before development |
| Separation of Concerns | Break into smaller functional pieces |
// Mock data pattern for prototyping
const MOCK_ACCOUNTS = [
{ Id: '001MOCK001', Name: 'Acme Corp', Industry: 'Technology' },
{ Id: '001MOCK002', Name: 'Global Inc', Industry: 'Finance' }
];
export default class AccountPrototype extends LightningElement {
accounts = MOCK_ACCOUNTS; // Replace with wire/Apex later
}
I - Integrate
Purpose: Determine how components interact with data systems.
Integration Checklist:
- Implement error handling with clear user notifications
- Add loading spinners to prevent duplicate requests
- Use LDS for single-object operations (minimizes DML)
- Respect FLS and CRUD in Apex implementations
- Store
wiredResultforrefreshApex()support
C - Composition
Purpose: Structure how LWCs nest and communicate.
Best Practices:
- Maintain shallow component hierarchies (max 3-4 levels)
- Single responsibility per component
- Clean up subscriptions in
disconnectedCallback() - Use custom events purposefully, not for every interaction
// Parent-managed composition pattern
// parent.js
handleChildEvent(event) {
this.selectedId = event.detail.id;
// Update child via @api
this.template.querySelector('c-child').selectedId = this.selectedId;
}
K - Kinetics
Purpose: Manage user interaction and event responsiveness.
// Debounce pattern for search
delayTimeout;
handleSearchChange(event) {
const searchTerm = event.target.value;
clearTimeout(this.delayTimeout);
this.delayTimeout = setTimeout(() => {
this.dispatchEvent(new CustomEvent('search', {
detail: { searchTerm }
}));
}, 300);
}
L - Libraries
Purpose: Leverage Salesforce-provided and platform tools.
Recommended Platform Features:
| API/Module | Use Case |
|---|---|
lightning/navigation |
Page/record navigation |
lightning/uiRecordApi |
LDS operations (getRecord, updateRecord) |
lightning/platformShowToastEvent |
User notifications |
lightning/modal |
Native modal dialogs |
| Base Components | Pre-built UI (button, input, datatable) |
lightning/refresh |
Dispatch refresh events |
Avoid reinventing what base components already provide!
E - Execution
Purpose: Optimize performance and resource efficiency.
Performance Checklist:
- Lazy load with
if:true/lwc:if - Use
keydirective in iterations - Cache computed values in getters
- Avoid property updates that trigger re-renders
- Use browser DevTools Performance tab
S - Security
Purpose: Enforce access control and data protection.
// Secure Apex pattern
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts(String searchTerm) {
String searchKey = '%' + String.escapeSingleQuotes(searchTerm) + '%';
return [
SELECT Id, Name, Industry
FROM Account
WHERE Name LIKE :searchKey
WITH SECURITY_ENFORCED
LIMIT 50
];
}
Wire Service Patterns
Wire vs Imperative Apex Calls
LWC can interact with Apex in two ways: @wire (reactive/declarative) and imperative calls (manual/programmatic). Understanding when to use each is critical for building performant, maintainable components.
Quick Comparison
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ WIRE vs IMPERATIVE APEX CALLS │
├──────────────────┬──────────────────────────────┬────────────────────────────────────┤
│ Aspect │ Wire (@wire) │ Imperative Calls │
├──────────────────┼──────────────────────────────┼────────────────────────────────────┤
│ Execution │ Automatic / Reactive │ Manual / Programmatic │
│ DML Operations │ ❌ Read-Only │ ✅ Insert / Update / Delete │
│ Data Updates │ ✅ Auto on Parameter Change │ ❌ Manual Refresh Required │
│ Control │ ⚠️ Low (framework decides) │ ✅ Full (you decide when/how) │
│ Error Handling │ ✅ Framework Managed │ ⚠️ Developer Managed │
│ Supported Objects│ ⚠️ UI API Only │ ✅ All Objects │
│ Caching │ ✅ Built-in (cacheable=true) │ ❌ No automatic caching │
└──────────────────┴──────────────────────────────┴────────────────────────────────────┘
Pros & Cons
| Wire (@wire) | Imperative Calls |
|---|---|
| ✅ Auto UI sync & caching | ✅ Supports DML & all objects |
| ✅ Less boilerplate code | ✅ Full control over timing |
| ✅ Reactive to parameter changes | ✅ Can handle complex logic |
| ❌ Read-only, limited objects | ❌ Manual handling, no auto refresh |
| ❌ Can't control execution timing | ❌ More error handling code needed |
When to Use Each
Use Wire (@wire) when:
- 📌 Read-only data display
- 📌 Auto-refresh UI when parameters change
- 📌 Stable parameters (recordId, filter values)
- 📌 Working with UI API supported objects
Use Imperative Calls when:
- 📌 User actions (clicks, form submissions)
- 📌 DML operations (Insert, Update, Delete)
- 📌 Dynamic parameters determined at runtime
- 📌 Custom objects or complex queries
- 📌 Need control over execution timing
Side-by-Side Code Examples
Wire Example - Data loads automatically when selectedIndustry changes:
import { LightningElement, wire } from 'lwc';
import fetchAccounts from '@salesforce/apex/AccountController.fetchAccounts';
export default class WireExample extends LightningElement {
selectedIndustry = 'Technology';
accounts;
error;
// Automatically re-fetches when selectedIndustry changes
@wire(fetchAccounts, { industry: '$selectedIndustry' })
wiredAccounts({ data, error }) {
if (data) {
this.accounts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.accounts = undefined;
}
}
}
Imperative Example - Data loads only when user triggers action:
import { LightningElement } from 'lwc';
import fetchAccounts from '@salesforce/apex/AccountController.fetchAccounts';
export default class ImperativeExample extends LightningElement {
selectedIndustry = 'Technology';
accounts;
error;
isLoading = false;
// Called explicitly when user clicks button or submits form
async fetchAccounts() {
this.isLoading = true;
try {
this.accounts = await fetchAccounts({
industry: this.selectedIndustry
});
this.error = undefined;
} catch (error) {
this.error = error;
this.accounts = undefined;
} finally {
this.isLoading = false;
}
}
}
Decision Tree
┌─────────────────────────────┐
│ Need to modify data? │
│ (Insert/Update/Delete) │
└─────────────┬───────────────┘
│
┌─────────────┴───────────────┐
│ │
YES NO
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────────┐
│ IMPERATIVE │ │ Should data auto- │
│ (Use await) │ │ refresh on param │
└─────────────────┘ │ change? │
└───────────┬─────────────┘
│
┌───────────┴───────────┐
│ │
YES NO
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ @WIRE │ │ IMPERATIVE │
│ (Reactive) │ │ (On-demand) │
└─────────────────┘ └─────────────────┘
1. Basic Data Display (Wire Service)
// accountCard.js
import { LightningElement, api, 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 AccountCard extends LightningElement {
@api recordId;
@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);
}
get isLoading() {
return !this.account.data && !this.account.error;
}
}
<!-- accountCard.html -->
<template>
<template lwc:if={isLoading}>
<lightning-spinner alternative-text="Loading"></lightning-spinner>
</template>
<template lwc:if={account.data}>
<div class="slds-box slds-theme_default">
<h2 class="slds-text-heading_medium">{name}</h2>
<p class="slds-text-color_weak">{industry}</p>
</div>
</template>
<template lwc:if={account.error}>
<p class="slds-text-color_error">{account.error.body.message}</p>
</template>
</template>
2. Wire Service with Apex
// contactList.js
import { LightningElement, api, wire } from 'lwc';
import getContacts from '@salesforce/apex/ContactController.getContacts';
import { refreshApex } from '@salesforce/apex';
export default class ContactList extends LightningElement {
@api recordId;
contacts;
error;
wiredContactsResult;
@wire(getContacts, { accountId: '$recordId' })
wiredContacts(result) {
this.wiredContactsResult = result; // Store for refreshApex
const { error, data } = result;
if (data) {
this.contacts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.contacts = undefined;
}
}
async handleRefresh() {
await refreshApex(this.wiredContactsResult);
}
}
GraphQL Patterns
Module Note:
lightning/graphqlsupersedeslightning/uiGraphQLApiand provides newer features like mutations, optional fields, and dynamic query construction.
GraphQL Query (Wire Adapter)
// graphqlContacts.js
import { LightningElement, wire } from 'lwc';
import { gql, graphql } from 'lightning/graphql';
const CONTACTS_QUERY = gql`
query ContactsQuery($first: Int, $after: String) {
uiapi {
query {
Contact(first: $first, after: $after) {
edges {
node {
Id
Name { value }
Email { value }
Account {
Name { value }
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
export default class GraphqlContacts extends LightningElement {
contacts;
pageInfo;
error;
_cursor;
@wire(graphql, {
query: CONTACTS_QUERY,
variables: '$queryVariables'
})
wiredContacts({ data, error }) {
if (data) {
const result = data.uiapi.query.Contact;
this.contacts = result.edges.map(edge => ({
id: edge.node.Id,
name: edge.node.Name.value,
email: edge.node.Email?.value,
accountName: edge.node.Account?.Name?.value
}));
this.pageInfo = result.pageInfo;
} else if (error) {
this.error = error;
}
}
get queryVariables() {
return { first: 10, after: this._cursor };
}
loadMore() {
if (this.pageInfo?.hasNextPage) {
this._cursor = this.pageInfo.endCursor;
}
}
}
GraphQL Mutations (Spring '26 - GA in API 66.0)
Mutations allow create, update, and delete operations via GraphQL. Use executeMutation for imperative operations.
// graphqlAccountMutation.js
import { LightningElement, track } from 'lwc';
import { gql, executeMutation } from 'lightning/graphql';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
// Create mutation
const CREATE_ACCOUNT = gql`
mutation CreateAccount($name: String!, $industry: String) {
uiapi {
AccountCreate(input: {
Account: {
Name: $name
Industry: $industry
}
}) {
Record {
Id
Name { value }
Industry { value }
}
}
}
}
`;
// Update mutation
const UPDATE_ACCOUNT = gql`
mutation UpdateAccount($id: ID!, $name: String!) {
uiapi {
AccountUpdate(input: {
Account: {
Id: $id
Name: $name
}
}) {
Record {
Id
Name { value }
}
}
}
}
`;
// Delete mutation
const DELETE_ACCOUNT = gql`
mutation DeleteAccount($id: ID!) {
uiapi {
AccountDelete(input: { Account: { Id: $id } }) {
Id
}
}
}
`;
export default class GraphqlAccountMutation extends LightningElement {
@track accountName = '';
@track industry = '';
isLoading = false;
handleNameChange(event) {
this.accountName = event.target.value;
}
handleIndustryChange(event) {
this.industry = event.target.value;
}
async handleCreate() {
if (!this.accountName) return;
this.isLoading = true;
try {
const result = await executeMutation(CREATE_ACCOUNT, {
variables: {
name: this.accountName,
industry: this.industry || null
}
});
const newRecord = result.data.uiapi.AccountCreate.Record;
this.showToast('Success', `Account "${newRecord.Name.value}" created`, 'success');
this.resetForm();
} catch (error) {
this.handleError(error);
} finally {
this.isLoading = false;
}
}
async handleUpdate(accountId, newName) {
try {
const result = await executeMutation(UPDATE_ACCOUNT, {
variables: { id: accountId, name: newName }
});
this.showToast('Success', 'Account updated', 'success');
return result.data.uiapi.AccountUpdate.Record;
} catch (error) {
this.handleError(error);
}
}
async handleDelete(accountId) {
try {
await executeMutation(DELETE_ACCOUNT, {
variables: { id: accountId }
});
this.showToast('Success', 'Account deleted', 'success');
} catch (error) {
this.handleError(error);
}
}
handleError(error) {
const message = error.graphQLErrors
? error.graphQLErrors.map(e => e.message).join(', ')
: error.message || 'Unknown error';
this.showToast('Error', message, 'error');
}
showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
}
resetForm() {
this.accountName = '';
this.industry = '';
}
}
GraphQL Mutation Operations
| Operation | Mutation Type | Notes |
|---|---|---|
| Create | {Object}Create |
Can request fields from newly created record |
| Update | {Object}Update |
Cannot query fields in same request |
| Delete | {Object}Delete |
Cannot query fields in same request |
allOrNone Parameter
Control transaction behavior with allOrNone (default: true):
const BATCH_CREATE = gql`
mutation BatchCreate($allOrNone: Boolean = true) {
uiapi(allOrNone: $allOrNone) {
acc1: AccountCreate(input: { Account: { Name: "Account 1" } }) {
Record { Id }
}
acc2: AccountCreate(input: { Account: { Name: "Account 2" } }) {
Record { Id }
}
}
}
`;
// If allOrNone=true: All rollback if any fails
// If allOrNone=false: Only failed operations rollback
Modal Component Pattern
Default to the native lightning/modal service — see assets/native-modal-component. It ships focus trap, ESC-to-close, ARIA, and size variants out of the box, so there's nothing to reinvent. Confirm it's supported on the target surface (some Experience Builder site templates historically lagged Lightning Experience support) before falling back to a custom implementation.
Only reach for a custom composable modal below when lightning/modal genuinely can't cover the case (e.g. a confirmed gap on your site template, or composition needs beyond what LightningModal slots support). It's based on James Simone's composable modal pattern; see assets/modal-component for the fuller version with cleanup and multi-slot composition. Prefer a save/cancel CustomEvent over a function-reference @api prop for the save handler — function references can't be passed from Aura markup, which breaks on Aura-based Experience Builder pages.
// composableModal.js
import { LightningElement, api } from 'lwc';
const OUTER_MODAL_CLASS = 'outerModalContent';
export default class ComposableModal extends LightningElement {
@api modalHeader;
@api modalTagline;
@api modalSaveHandler;
_isOpen = false;
_focusableElements = [];
@api
toggleModal() {
this._isOpen = !this._isOpen;
if (this._isOpen) {
this._focusableElements = [...this.querySelectorAll('.focusable')];
this._focusFirstElement();
window.addEventListener('keyup', this._handleKeyUp);
} else {
window.removeEventListener('keyup', this._handleKeyUp);
}
}
get modalAriaHidden() {
return !this._isOpen;
}
get modalClass() {
return this._isOpen
? 'slds-modal slds-visible slds-fade-in-open'
: 'slds-modal slds-hidden';
}
get backdropClass() {
return this._isOpen ? 'slds-backdrop slds-backdrop_open' : 'slds-backdrop';
}
_handleKeyUp = (event) => {
if (event.code === 'Escape') {
this.toggleModal();
} else if (event.code === 'Tab') {
this._handleTabNavigation(event);
}
}
_handleTabNavigation(event) {
// Focus trap logic - keep focus within modal
const activeEl = this.template.activeElement;
const lastIndex = this._focusableElements.length - 1;
const currentIndex = this._focusableElements.indexOf(activeEl);
if (event.shiftKey && currentIndex === 0) {
this._focusableElements[lastIndex]?.focus();
} else if (!event.shiftKey && currentIndex === lastIndex) {
this._focusFirstElement();
}
}
_focusFirstElement() {
if (this._focusableElements.length > 0) {
this._focusableElements[0].focus();
}
}
handleBackdropClick(event) {
if (event.target.classList.contains(OUTER_MODAL_CLASS)) {
this.toggleModal();
}
}
handleSave() {
if (this.modalSaveHandler) {
this.modalSaveHandler();
}
this.toggleModal();
}
disconnectedCallback() {
window.removeEventListener('keyup', this._handleKeyUp);
}
}
<!-- composableModal.html -->
<template>
<!-- Backdrop -->
<div class={backdropClass}></div>
<!-- Modal -->
<div class={modalClass}
role="dialog"
aria-modal="true"
aria-hidden={modalAriaHidden}
aria-labelledby="modal-heading">
<div class="slds-modal__container outerModalContent"
onclick={handleBackdropClick}>
<div class="slds-modal__content slds-p-around_medium">
<!-- Header -->
<template lwc:if={modalHeader}>
<h2 id="modal-heading" class="slds-text-heading_medium">
{modalHeader}
</h2>
</template>
<template lwc:if={modalTagline}>
<p class="slds-m-top_x-small slds-text-color_weak">
{modalTagline}
</p>
</template>
<!-- Slotted Content -->
<div class="slds-m-top_medium">
<slot name="modalContent"></slot>
</div>
<!-- Footer -->
<div class="slds-m-top_medium slds-text-align_right">
<lightning-button
label="Cancel"
onclick={toggleModal}
class="slds-m-right_x-small focusable">
</lightning-button>
<lightning-button
variant="brand"
label="Save"
onclick={handleSave}
class="focusable">
</lightning-button>
</div>
</div>
</div>
</div>
<!-- Hidden background content -->
<div aria-hidden={_isOpen}>
<slot name="body"></slot>
</div>
</template>
Record Picker Pattern
// recordPicker.js
import { LightningElement, api } from 'lwc';
export default class RecordPicker extends LightningElement {
@api label = 'Select Record';
@api objectApiName = 'Account';
@api placeholder = 'Search...';
@api required = false;
@api multiSelect = false;
_selectedIds = [];
@api
get value() {
return this.multiSelect ? this._selectedIds : this._selectedIds[0];
}
set value(val) {
this._selectedIds = Array.isArray(val) ? val : val ? [val] : [];
}
handleChange(event) {
const recordId = event.detail.recordId;
if (this.multiSelect) {
if (!this._selectedIds.includes(recordId)) {
this._selectedIds = [...this._selectedIds, recordId];
}
} else {
this._selectedIds = recordId ? [recordId] : [];
}
this.dispatchEvent(new CustomEvent('select', {
detail: {
recordId: this.value,
recordIds: this._selectedIds
}
}));
}
handleRemove(event) {
const idToRemove = event.target.dataset.id;
this._selectedIds = this._selectedIds.filter(id => id !== idToRemove);
this.dispatchEvent(new CustomEvent('select', {
detail: { recordIds: this._selectedIds }
}));
}
}
<!-- recordPicker.html -->
<template>
<lightning-record-picker
label={label}
placeholder={placeholder}
object-api-name={objectApiName}
onchange={handleChange}
required={required}>
</lightning-record-picker>
<template lwc:if={multiSelect}>
<div class="slds-m-top_x-small">
<template for:each={_selectedIds} for:item="id">
<lightning-pill
key={id}
label={id}
data-id={id}
onremove={handleRemove}>
</lightning-pill>
</template>
</div>
</template>
</template>
Workspace API Pattern
// workspaceTabManager.js
import { LightningElement, wire } from 'lwc';
import { IsConsoleNavigation, getFocusedTabInfo, openTab, closeTab,
setTabLabel, setTabIcon, refreshTab } from 'lightning/platformWorkspaceApi';
export default class WorkspaceTabManager extends LightningElement {
@wire(IsConsoleNavigation) isConsole;
async openRecordTab(recordId, objectApiName) {
if (!this.isConsole) return;
await openTab({
recordId,
focus: true,
icon: `standard:${objectApiName.toLowerCase()}`,
label: 'Loading...'
});
}
async openSubtab(parentTabId, recordId) {
if (!this.isConsole) return;
await openTab({
parentTabId,
recordId,
focus: true
});
}
async getCurrentTabInfo() {
if (!this.isConsole) return null;
return await getFocusedTabInfo();
}
async updateTabLabel(tabId, label) {
if (!this.isConsole) return;
await setTabLabel(tabId, label);
}
async updateTabIcon(tabId, iconName) {
if (!this.isConsole) return;
await setTabIcon(tabId, iconName);
}
async refreshCurrentTab() {
if (!this.isConsole) return;
const tabInfo = await getFocusedTabInfo();
await refreshTab(tabInfo.tabId);
}
async closeCurrentTab() {
if (!this.isConsole) return;
const tabInfo = await getFocusedTabInfo();
await closeTab(tabInfo.tabId);
}
}
Parent-Child Communication
// parent.js
import { LightningElement } from 'lwc';
export default class Parent extends LightningElement {
selectedAccountId;
handleAccountSelected(event) {
this.selectedAccountId = event.detail.accountId;
}
}
<!-- parent.html -->
<template>
<c-account-list onaccountselected={handleAccountSelected}></c-account-list>
<template lwc:if={selectedAccountId}>
<c-account-detail account-id={selectedAccountId}></c-account-detail>
</template>
</template>
// child.js (accountList)
import { LightningElement } from 'lwc';
export default class AccountList extends LightningElement {
handleRowAction(event) {
const accountId = event.detail.row.Id;
// Dispatch event to parent
this.dispatchEvent(new CustomEvent('accountselected', {
detail: { accountId },
bubbles: true,
composed: false // Don't cross shadow DOM boundaries
}));
}
}
Sibling Communication (via Parent)
When two child components need to communicate but share the same parent, use the parent as middleware. This is the recommended pattern for master-detail UIs.
┌─────────────────────────────────────────────────────────────────────┐
│ SIBLING COMMUNICATION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ Parent │ ← Manages state │
│ └────┬─────┘ │
│ ┌─────────┴─────────┐ │
│ │ │ │
│ CustomEvent @api property │
│ (up) (down) │
│ │ │ │
│ ┌─────┴─────┐ ┌─────┴─────┐ │
│ │ Child A │ │ Child B │ │
│ │ (List) │ │ (Detail) │ │
│ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
The flow:
- Child A dispatches a custom event (e.g., user selects an account)
- Parent catches the event and updates its state
- Parent passes data to Child B via
@apiproperty
Complete Example: Account List → Account Detail
// accountContainer.js - Parent orchestrates communication between siblings
import { LightningElement } from 'lwc';
export default class AccountContainer extends LightningElement {
// State managed at parent level
selectedAccountId;
selectedAccountName;
// Child A (accountList) fires this event
handleAccountSelect(event) {
this.selectedAccountId = event.detail.accountId;
this.selectedAccountName = event.detail.accountName;
}
// Clear selection (triggered by Child B)
handleClearSelection() {
this.selectedAccountId = null;
this.selectedAccountName = null;
}
get hasSelection() {
return !!this.selectedAccountId;
}
}
<!-- accountContainer.html -->
<template>
<div class="slds-grid slds-gutters">
<!-- Child A: Account List -->
<div class="slds-col slds-size_1-of-2">
<c-account-list
onaccountselect={handleAccountSelect}
selected-id={selectedAccountId}>
</c-account-list>
</div>
<!-- Child B: Account Detail (receives data via @api) -->
<div class="slds-col slds-size_1-of-2">
<template lwc:if={hasSelection}>
<c-account-detail
account-id={selectedAccountId}
account-name={selectedAccountName}
onclearselection={handleClearSelection}>
</c-account-detail>
</template>
<template lwc:else>
<div class="slds-box slds-theme_shade">
Select an account to view details
</div>
</template>
</div>
</div>
</template>
// accountList.js - Child A: Dispatches events UP to parent
import { LightningElement, api, wire } from 'lwc';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
export default class AccountList extends LightningElement {
@api selectedId; // Highlight selected row (from parent)
accounts;
error;
@wire(getAccounts)
wiredAccounts({ data, error }) {
if (data) {
this.accounts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.accounts = undefined;
}
}
handleRowClick(event) {
const accountId = event.currentTarget.dataset.id;
const accountName = event.currentTarget.dataset.name;
// Dispatch event to parent (not bubbles - parent listens directly)
this.dispatchEvent(new CustomEvent('accountselect', {
detail: { accountId, accountName }
}));
}
// Computed: Check if row should be highlighted
getRowClass(accountId) {
return accountId === this.selectedId
? 'slds-item slds-is-selected'
: 'slds-item';
}
}
// accountDetail.js - Child B: Receives data via @api from parent
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
import REVENUE_FIELD from '@salesforce/schema/Account.AnnualRevenue';
const FIELDS = [INDUSTRY_FIELD, REVENUE_FIELD];
export default class AccountDetail extends LightningElement {
@api accountId; // Received from parent
@api accountName; // Received from parent
@wire(getRecord, { recordId: '$accountId', fields: FIELDS })
account;
get industry() {
return getFieldValue(this.account.data, INDUSTRY_FIELD);
}
get revenue() {
return getFieldValue(this.account.data, REVENUE_FIELD);
}
get isLoading() {
return !this.account.data && !this.account.error;
}
handleClose() {
// Dispatch event back to parent to clear selection
this.dispatchEvent(new CustomEvent('clearselection'));
}
}
When to Use Sibling Pattern vs LMS
| Scenario | Sibling Pattern | LMS |
|---|---|---|
| Components share same parent | ✅ Recommended | ❌ Overkill |
| State is simple (1-2 values) | ✅ | ❌ |
| Need bidirectional updates | ✅ | ✅ |
| Components in different DOM trees | ❌ | ✅ Required |
| Cross-framework (LWC ↔ Aura) | ❌ | ✅ Required |
| Many consumers need same data | ❌ Consider LMS | ✅ |
| Component hierarchy is deep (4+ levels) | ❌ Consider LMS | ✅ |
Rule of thumb: If components share a parent and data flow is simple, use sibling pattern. If components are "far apart" in the DOM or you need pub/sub semantics, use LMS.
Navigation Patterns
// navigator.js
import { LightningElement } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
export default class Navigator extends NavigationMixin(LightningElement) {
navigateToRecord(recordId, objectApiName = 'Account') {
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId,
objectApiName,
actionName: 'view'
}
});
}
navigateToList(objectApiName, filterName = 'Recent') {
this[NavigationMixin.Navigate]({
type: 'standard__objectPage',
attributes: {
objectApiName,
actionName: 'list'
},
state: { filterName }
});
}
navigateToNewRecord(objectApiName, defaultValues = {}) {
this[NavigationMixin.Navigate]({
type: 'standard__objectPage',
attributes: {
objectApiName,
actionName: 'new'
},
state: {
defaultFieldValues: Object.entries(defaultValues)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join(',')
}
});
}
navigateToRelatedList(recordId, relationshipApiName) {
this[NavigationMixin.Navigate]({
type: 'standard__recordRelationshipPage',
attributes: {
recordId,
relationshipApiName,
actionName: 'view'
}
});
}
navigateToNamedPage(pageName, params = {}) {
this[NavigationMixin.Navigate]({
type: 'standard__namedPage',
attributes: {
pageName
},
state: params
});
}
}
TypeScript Patterns
TypeScript Component Pattern
// accountList.ts
import { LightningElement, api, wire, track } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
import ACCOUNT_NAME_FIELD from '@salesforce/schema/Account.Name';
// Define interfaces for type safety
interface AccountRecord {
Id: string;
Name: string;
Industry?: string;
AnnualRevenue?: number;
}
interface WireResult<T> {
data?: T;
error?: Error;
}
export default class AccountList extends LightningElement {
// Typed @api properties
@api recordId: string | undefined;
@api
get maxRecords(): number {
return this._maxRecords;
}
set maxRecords(value: number) {
this._maxRecords = value;
}
// Typed @track properties
@track private _accounts: AccountRecord[] = [];
@track private _error: string | null = null;
private _maxRecords: number = 10;
private _wiredResult: WireResult<AccountRecord[]> | undefined;
// Typed wire service
@wire(getAccounts, { maxRecords: '$maxRecords' })
wiredAccounts(result: WireResult<AccountRecord[]>): void {
this._wiredResult = result;
const { data, error } = result;
if (data) {
this._accounts = data;
this._error = null;
} else if (error) {
this._error = this.reduceErrors(error);
this._accounts = [];
}
}
// Typed getters
get accounts(): AccountRecord[] {
return this._accounts;
}
get hasAccounts(): boolean {
return this._accounts.length > 0;
}
// Typed event handlers
handleSelect(event: CustomEvent<{ accountId: string }>): void {
const { accountId } = event.detail;
this.dispatchEvent(new CustomEvent('accountselected', {
detail: { accountId },
bubbles: true,
composed: true
}));
}
// Typed utility methods
private reduceErrors(error: Error | Error[]): string {
const errors = Array.isArray(error) ? error : [error];
return errors
.filter((e): e is Error => e !== null)
.map(e => e.message || 'Unknown error')
.join('; ');
}
}
TypeScript Jest Test Pattern
// accountList.test.ts
import { createElement, LightningElement } from 'lwc';
import AccountList from 'c/accountList';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
// Type definitions for tests
interface AccountRecord {
Id: string;
Name: string;
Industry?: string;
}
// Mock Apex
jest.mock(
'@salesforce/apex/AccountController.getAccounts',
() => ({ default: jest.fn() }),
{ virtual: true }
);
const MOCK_ACCOUNTS: AccountRecord[] = [
{ Id: '001xx000003DGQ', Name: 'Acme Corp', Industry: 'Technology' }
];
describe('c-account-list', () => {
let element: LightningElement & { maxRecords?: number };
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
it('displays accounts after data loads', async () => {
(getAccounts as jest.Mock).mockResolvedValue(MOCK_ACCOUNTS);
element = createElement('c-account-list', { is: AccountList });
document.body.appendChild(element);
await Promise.resolve();
const items = element.shadowRoot?.querySelectorAll('.slds-item');
expect(items?.length).toBe(MOCK_ACCOUNTS.length);
});
});
TypeScript Features for LWC
| Feature | LWC Support | Notes |
|---|---|---|
| Interface definitions | ✅ | Define shapes for records, events, props |
| Typed @api properties | ✅ | Getter/setter patterns with types |
| Typed @wire results | ✅ | Generic WireResult<T> pattern |
| Typed event handlers | ✅ | CustomEvent<T> for event detail typing |
| Private class fields | ✅ | Use private keyword |
| Strict null checking | ✅ | Optional chaining ?. and nullish coalescing ?? |
Apex Controller Patterns
Cacheable Methods (for @wire)
public with sharing class LwcController {
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts(String searchTerm) {
String searchKey = '%' + String.escapeSingleQuotes(searchTerm) + '%';
return [
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE Name LIKE :searchKey
WITH SECURITY_ENFORCED
ORDER BY Name
LIMIT 50
];
}
@AuraEnabled(cacheable=true)
public static List<PicklistOption> getIndustryOptions() {
List<PicklistOption> options = new List<PicklistOption>();
Schema.DescribeFieldResult fieldResult =
Account.Industry.getDescribe();
for (Schema.PicklistEntry entry : fieldResult.getPicklistValues()) {
if (entry.isActive()) {
options.add(new PicklistOption(entry.getLabel(), entry.getValue()));
}
}
return options;
}
public class PicklistOption {
@AuraEnabled public String label;
@AuraEnabled public String value;
public PicklistOption(String label, String value) {
this.label = label;
this.value = value;
}
}
}
Non-Cacheable Methods (for DML)
@AuraEnabled
public static Account createAccount(String accountJson) {
Account acc = (Account) JSON.deserialize(accountJson, Account.class);
// FLS check
SObjectAccessDecision decision = Security.stripInaccessible(
AccessType.CREATABLE,
new List<Account>{ acc }
);
insert decision.getRecords();
return (Account) decision.getRecords()[0];
}
@AuraEnabled
public static void deleteAccounts(List<Id> accountIds) {
if (accountIds == null || accountIds.isEmpty()) {
throw new AuraHandledException('No accounts to delete');
}
List<Account> toDelete = [
SELECT Id FROM Account
WHERE Id IN :accountIds
WITH SECURITY_ENFORCED
];
delete toDelete;
}
Error Handling Pattern
@AuraEnabled
public static List<Contact> getContactsWithErrorHandling(Id accountId) {
try {
if (accountId == null) {
throw new AuraHandledException('Account ID is required');
}
List<Contact> contacts = [
SELECT Id, Name, Email, Phone
FROM Contact
WHERE AccountId = :accountId
WITH SECURITY_ENFORCED
ORDER BY Name
LIMIT 100
];
return contacts;
} catch (Exception e) {
throw new AuraHandledException('Error fetching contacts: ' + e.getMessage());
}
}
Related Resources
- lms-guide.md - Lightning Message Service deep dive
- jest-testing.md - Advanced testing patterns
- accessibility-guide.md - WCAG compliance
- performance-guide.md - Optimization techniques
External References
- PICKLES Framework — David Picksley, Third Eye Consulting
- LWC Recipes (GitHub)
- James Simone - Composable Modal