Lightning Web Components (LWC) is Salesforce's modern UI framework built on web standards — custom elements, shadow DOM, and ES modules. Unlike Aura, LWC uses native browser APIs, making it significantly faster and easier to maintain.
Component Anatomy
Every LWC component is a folder with at minimum three files: an HTML template, a JavaScript controller, and an XML metadata file. The folder name defines the component's tag name.
<!-- accountCard/accountCard.html -->
<template>
<lightning-card title={account.Name} icon-name="standard:account">
<div class="slds-card__body slds-card__body_inner">
<!-- Conditional rendering -->
<template lwc:if={isLoaded}>
<p>Industry: <strong>{account.Industry}</strong></p>
<p>Revenue: <strong>{formattedRevenue}</strong></p>
</template>
<template lwc:else>
<lightning-spinner alternative-text="Loading"></lightning-spinner>
</template>
<!-- Iterating lists -->
<template for:each={contacts} for:item="contact">
<p key={contact.Id}>{contact.Name}</p>
</template>
</div>
<div slot="footer">
<lightning-button label="Edit" onclick={handleEdit}></lightning-button>
</div>
</lightning-card>
</template>
import { LightningElement, api, track, wire } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import getAccountDetails from '@salesforce/apex/AccountController.getAccountDetails';
export default class AccountCard extends NavigationMixin(LightningElement) {
// @api — public property, settable by parent components
@api recordId;
// @track — not needed for objects/arrays in modern LWC (tracked by default)
isLoaded = false;
contacts = [];
// Computed property — getter pattern
get formattedRevenue() {
return this.account?.AnnualRevenue
? '$' + this.account.AnnualRevenue.toLocaleString()
: 'N/A';
}
handleEdit() {
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: this.recordId,
actionName: 'edit'
}
});
}
}
Lifecycle Hooks
LWC components go through a defined lifecycle. Understanding each hook is critical for placing initialization logic, DOM manipulation, and cleanup code correctly.
Called when component is created. Don't access child elements yet — they don't exist. Use for initial property setup.
Fires when component is inserted into DOM. Safe to access this.template but child slots may not be rendered yet.
Fires after every render. Can fire multiple times — use a boolean guard to prevent repeated initialization. Safe to query child DOM.
Fires when component is removed from DOM. Clean up event listeners, timers, and subscriptions here to prevent memory leaks.
import { LightningElement } from 'lwc';
import { subscribe, unsubscribe, MessageContext } from 'lightning/messageService';
export default class MyComponent extends LightningElement {
_initialized = false;
_subscription = null;
_intervalId = null;
connectedCallback() {
// Subscribe to message channel
this._subscription = subscribe(this.messageContext, MY_CHANNEL, (msg) => {
this.handleMessage(msg);
});
// Start polling
this._intervalId = setInterval(() => this.refresh(), 30000);
}
renderedCallback() {
// Only run once, even though this fires on every render
if (this._initialized) return;
this._initialized = true;
// Safe to access child elements now
const canvas = this.template.querySelector('canvas');
this.initChart(canvas);
}
disconnectedCallback() {
// Always clean up to prevent memory leaks
unsubscribe(this._subscription);
clearInterval(this._intervalId);
}
}
Wire Service
The @wire decorator is the reactive way to fetch data and metadata in LWC. It automatically re-fetches when its reactive properties change and works with both Apex methods and Salesforce data APIs.
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import { getPicklistValues, getObjectInfo } from 'lightning/uiObjectInfoApi';
import ACCOUNT_OBJECT from '@salesforce/schema/Account';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
import getRelatedContacts from '@salesforce/apex/AccountController.getRelatedContacts';
export default class WireDemo extends LightningElement {
@api recordId;
// Wire to standard record API — auto-refreshes on recordId change
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, INDUSTRY_FIELD] })
account;
// Wire to Apex — cacheable = true required for @wire
@wire(getRelatedContacts, { accountId: '$recordId' })
wiredContacts({ error, data }) {
if (data) {
this.contacts = data.map(c => ({
...c,
displayName: c.FirstName + ' ' + c.LastName
}));
} else if (error) {
this.error = error.body.message;
}
}
// Wire to picklist values
@wire(getObjectInfo, { objectApiName: ACCOUNT_OBJECT })
objectInfo;
@wire(getPicklistValues, {
recordTypeId: '$objectInfo.data.defaultRecordTypeId',
fieldApiName: INDUSTRY_FIELD
})
industryPicklist;
// Computed getter using wire data
get accountName() {
return getFieldValue(this.account.data, NAME_FIELD);
}
}
Custom Events & Communication
LWC uses a clear communication model: properties down and events up. Child components dispatch custom events; parent components listen and react.
Communication Pattern: Pass data down via @api properties. Communicate upward via custom CustomEvent. For unrelated components, use Lightning Message Service (LMS).
// childForm.js — dispatches event to parent
export default class ChildForm extends LightningElement {
handleSave() {
const formData = {
name: this.template.querySelector('[data-field="name"]').value,
industry: this.template.querySelector('[data-field="industry"]').value
};
// Dispatch event with payload
this.dispatchEvent(
new CustomEvent('save', {
detail: formData,
bubbles: true, // bubbles up the DOM tree
composed: false // stays within shadow DOM by default
})
);
}
handleCancel() {
this.dispatchEvent(new CustomEvent('cancel'));
}
}
<!-- parentPage.html -->
<template>
<c-child-form
onsave={handleSave}
oncancel={handleCancel}>
</c-child-form>
</template>
// parentPage.js
export default class ParentPage extends LightningElement {
handleSave(event) {
const { name, industry } = event.detail;
// Call Apex or process data
createAccount({ name, industry })
.then(() => { this.showSuccess = true; })
.catch(err => { this.error = err.body.message; });
}
handleCancel() {
this[NavigationMixin.Navigate]({ type: 'standard__objectPage', ... });
}
}
Lightning Message Service
For communication between components that have no parent-child relationship — across different regions of the page — use Lightning Message Service (LMS).
// Publisher component
import { publish, MessageContext } from 'lightning/messageService';
import RECORD_SELECTED from '@salesforce/messageChannel/RecordSelected__c';
export default class Publisher extends LightningElement {
@wire(MessageContext) messageContext;
handleRecordSelect(event) {
publish(this.messageContext, RECORD_SELECTED, {
recordId: event.detail.id,
recordName: event.detail.name
});
}
}
// Subscriber component
import { subscribe, unsubscribe, MessageContext } from 'lightning/messageService';
import RECORD_SELECTED from '@salesforce/messageChannel/RecordSelected__c';
export default class Subscriber extends LightningElement {
@wire(MessageContext) messageContext;
_subscription;
connectedCallback() {
this._subscription = subscribe(
this.messageContext,
RECORD_SELECTED,
(message) => this.handleMessage(message)
);
}
disconnectedCallback() {
unsubscribe(this._subscription);
}
handleMessage(message) {
this.selectedId = message.recordId;
}
}