# LWC Component Patterns Comprehensive code examples for common Lightning Web Component patterns. --- ## Table of Contents 1. [PICKLES Framework Details](#pickles-framework-details) 2. [Wire Service Patterns](#wire-service-patterns) - [Wire vs Imperative Apex Calls](#wire-vs-imperative-apex-calls) 3. [GraphQL Patterns](#graphql-patterns) 4. [Modal Component Pattern](#modal-component-pattern) 5. [Record Picker Pattern](#record-picker-pattern) 6. [Workspace API Pattern](#workspace-api-pattern) 7. [Parent-Child Communication](#parent-child-communication) 8. [Sibling Communication (via Parent)](#sibling-communication-via-parent) 9. [Navigation Patterns](#navigation-patterns) 10. [TypeScript Patterns](#typescript-patterns) 11. [Apex Controller 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 | ```javascript // 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 `wiredResult` for `refreshApex()` 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 ```javascript // 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. ```javascript // 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 `key` directive 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. ```apex // Secure Apex pattern @AuraEnabled(cacheable=true) public static List 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 ```text ┌─────────────────────────────────────────────────────────────────────────────────────┐ │ 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: ```javascript 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: ```javascript 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 ```text ┌─────────────────────────────┐ │ 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) ```javascript // 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; } } ``` ```html ``` ### 2. Wire Service with Apex ```javascript // 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/graphql` supersedes `lightning/uiGraphQLApi` and provides newer features like mutations, optional fields, and dynamic query construction. ### GraphQL Query (Wire Adapter) ```javascript // 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. ```javascript // 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`): ```javascript 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](../assets/native-modal-component/nativeModalComponent.js). 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](https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/); see [assets/modal-component](../assets/modal-component/modalComponent.js) 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. ```javascript // 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); } } ``` ```html ``` --- ## Record Picker Pattern ```javascript // 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 } })); } } ``` ```html ``` --- ## Workspace API Pattern ```javascript // 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 ```javascript // parent.js import { LightningElement } from 'lwc'; export default class Parent extends LightningElement { selectedAccountId; handleAccountSelected(event) { this.selectedAccountId = event.detail.accountId; } } ``` ```html ``` ```javascript // 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. ```text ┌─────────────────────────────────────────────────────────────────────┐ │ SIBLING COMMUNICATION FLOW │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ │ │ │ Parent │ ← Manages state │ │ └────┬─────┘ │ │ ┌─────────┴─────────┐ │ │ │ │ │ │ CustomEvent @api property │ │ (up) (down) │ │ │ │ │ │ ┌─────┴─────┐ ┌─────┴─────┐ │ │ │ Child A │ │ Child B │ │ │ │ (List) │ │ (Detail) │ │ │ └───────────┘ └───────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` **The flow**: 1. **Child A** dispatches a custom event (e.g., user selects an account) 2. **Parent** catches the event and updates its state 3. **Parent** passes data to **Child B** via `@api` property ### Complete Example: Account List → Account Detail ```javascript // 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; } } ``` ```html ``` ```javascript // 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'; } } ``` ```javascript // 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 ```javascript // 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 ```typescript // 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 { 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 | undefined; // Typed wire service @wire(getAccounts, { maxRecords: '$maxRecords' }) wiredAccounts(result: WireResult): 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 ```typescript // 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` pattern | | **Typed event handlers** | ✅ | `CustomEvent` 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) ```apex public with sharing class LwcController { @AuraEnabled(cacheable=true) public static List 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 getIndustryOptions() { List options = new List(); 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) ```apex @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{ acc } ); insert decision.getRecords(); return (Account) decision.getRecords()[0]; } @AuraEnabled public static void deleteAccounts(List accountIds) { if (accountIds == null || accountIds.isEmpty()) { throw new AuraHandledException('No accounts to delete'); } List toDelete = [ SELECT Id FROM Account WHERE Id IN :accountIds WITH SECURITY_ENFORCED ]; delete toDelete; } ``` ### Error Handling Pattern ```apex @AuraEnabled public static List getContactsWithErrorHandling(Id accountId) { try { if (accountId == null) { throw new AuraHandledException('Account ID is required'); } List 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](lms-guide.md) - Lightning Message Service deep dive - [jest-testing.md](jest-testing.md) - Advanced testing patterns - [accessibility-guide.md](accessibility-guide.md) - WCAG compliance - [performance-guide.md](performance-guide.md) - Optimization techniques --- ## External References - [PICKLES Framework](https://www.salesforceben.com/the-ideal-framework-for-architecting-salesforce-lightning-web-components/) — David Picksley, Third Eye Consulting - [LWC Recipes (GitHub)](https://github.com/trailheadapps/lwc-recipes) - [James Simone - Composable Modal](https://www.jamessimone.net/blog/joys-of-apex/lwc-composable-modal/)