44 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
Based on James Simone's composable modal pattern.
// 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