In this guide
1 What is a Lightning Web Component?
A Lightning Web Component (LWC) is a custom HTML element built with modern JavaScript on top of native Web Components browser standards — Custom Elements, Shadow DOM, HTML templates, and ES modules. Salesforce introduced LWC in 2019 as a faster, lighter-weight alternative to the older Aura framework, and it's now the recommended way to build custom UI on the Lightning Platform.
Because LWC compiles almost directly to standards-based browser APIs (rather than a large custom JavaScript runtime like Aura), components render faster, ship smaller bundles, and are easier to unit test with familiar tools like Jest. LWC and Aura components can coexist and even nest inside each other, which is why most orgs migrate incrementally rather than rewriting everything at once.
Where LWCs run
A single LWC can be dropped into several different contexts without changing its code:
- Lightning App Builder — as a component on a Record Page, App Page, Home Page, or inside a Lightning Community/Experience Cloud page.
- Lightning Record Pages — automatically receiving the current record's Id via a reserved
@api recordIdproperty. - Utility bars, Quick Actions, and flow screens — as a Screen Flow component with input/output variables.
- Standalone LWC (Off-core) — Lightning Web Components can even run outside Salesforce entirely using the open-source LWC framework, though most Salesforce development targets the platform runtime.
2 Anatomy of an LWC
Every Lightning Web Component lives in its own folder and is made up of a small, fixed set of files that all share the component's name. At minimum you need an HTML file and a JavaScript file; the metadata file is required for the component to be usable anywhere in the org.
| File | Required? | Purpose |
|---|---|---|
accountCard.html | Yes | The component's markup — an HTML template compiled into the component's shadow DOM. |
accountCard.js | Yes | The component's JavaScript class, extending LightningElement, holding properties and methods. |
accountCard.js-meta.xml | For most uses | Configuration — which pages/orgs can use it, whether it's exposed, and any design-time properties for App Builder. |
accountCard.css | No | Scoped CSS — styles here only apply inside this component's shadow DOM, never leaking out or in. |
accountCard.svg | No | A custom icon shown for the component in Lightning App Builder. |
__tests__/accountCard.test.js | No | Jest unit tests, run entirely outside a Salesforce org using sfdx-lwc-jest. |
<template>
<lightning-card title="Account Details" icon-name="standard:account">
<div class="slds-p-horizontal_small">
<p>{accountName}</p>
</div>
</lightning-card>
</template>
import { LightningElement, api } from 'lwc';
export default class AccountCard extends LightningElement {
@api accountName;
}
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>60.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
</targets>
</LightningComponentBundle>
3 Decorators: @api, @track, @wire
LWC uses three special decorators, imported from the lwc module, to control how data flows into, out of, and reactively through a component.
| Decorator | Purpose | Reactive? |
|---|---|---|
@api | Exposes a property or method as public — settable by a parent component's template, or by App Builder/Flow as a configurable input. | Yes |
@track | Historically required to make deep mutations of objects/arrays reactive. Since Spring '20, all fields are reactive by default at the top level — @track is now rarely needed. | Yes |
@wire | Connects a property or function to a reactive data source (an LDS wire adapter or an @AuraEnabled(cacheable=true) Apex method) — LWC manages the subscription automatically. | Yes |
@api — public properties
A property marked @api can be set by whatever renders the component — a parent LWC's template, a Lightning Record Page, or a Flow Screen. On a Record Page, Salesforce automatically populates a reserved @api recordId property with the current record's Id, which is the standard way a component knows which record it's showing.
import { LightningElement, api } from 'lwc';
export default class ChildBadge extends LightningElement {
@api label; // set by the parent: <c-child-badge label="VIP"></c-child-badge>
}
@wire — declarative, reactive data
@wire is the idiomatic way to read data in LWC. You give it a wire adapter (or an Apex method) and it automatically calls it, re-calls it whenever any parameter changes, caches results client-side, and pushes the value/error into your component — no manual subscription or lifecycle management required.
import { LightningElement, wire } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
export default class AccountName extends LightningElement {
@api recordId;
// Property form — result lands directly on this.account
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
account;
// Function form — gives you full control over the {data, error} shape
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
wiredAccount({ data, error }) {
if (data) { this.accountName = data.fields.Name.value; }
if (error) { console.error(error); }
}
}
'$recordId' syntax — the leading $ tells LWC this parameter is reactive: whenever this.recordId changes, the wire re-fires automatically. Omit the $ and you'd be passing a literal string instead of the property's live value.4 Lifecycle Hooks
Every LWC goes through a predictable lifecycle as it's inserted into and removed from the DOM. LWC exposes hooks — plain methods you can optionally implement — at each key stage.
| Hook | Fires when | Common use |
|---|---|---|
constructor() | Component instance is created — before it's in the DOM. | Rarely used directly; must call super() first and cannot access @api properties yet. |
connectedCallback() | Component is inserted into the DOM. Can fire more than once if removed and re-inserted. | Kick off a wire-independent data fetch, subscribe to a Lightning Message Service channel, read @api properties. |
renderedCallback() | After every render — including the first. Fires frequently. | DOM measurements, integrating a third-party JS library that needs a real DOM node. Must be guarded to avoid infinite loops. |
disconnectedCallback() | Component is removed from the DOM. | Unsubscribe from message channels or clear intervals/timeouts to avoid memory leaks. |
errorCallback(error, stack) | A descendant component throws an error during rendering or in one of its lifecycle hooks. | Implement on a parent to build an error boundary that shows a fallback UI instead of a blank component. |
if (this.hasRendered) return; this.hasRendered = true;) when the logic should only run once.5 Calling Apex from LWC
LWC talks to Apex through methods annotated @AuraEnabled. There are two calling styles, and choosing the right one matters for both performance and correctness.
Wire (reactive, cacheable reads)
Use @wire for read-only data. The Apex method must be static and annotated @AuraEnabled(cacheable=true) — this tells the Lightning Data Service it's safe to cache the result client-side and reuse it across components without hitting the server again.
public with sharing class ContactController {
@AuraEnabled(cacheable=true)
public static List<Contact> getContactsByAccount(Id accountId) {
return [
SELECT Id, Name, Email
FROM Contact
WHERE AccountId = :accountId
WITH SECURITY_ENFORCED
LIMIT 50
];
}
}
import getContactsByAccount from '@salesforce/apex/ContactController.getContactsByAccount';
@wire(getContactsByAccount, { accountId: '$recordId' })
contacts;
Imperative (writes, and anything not cacheable)
Any Apex method that performs DML — or one you need to invoke on-demand (e.g. from a button click) rather than automatically — must be called imperatively. Import the same method, but call it as a function returning a Promise instead of using @wire.
import createContact from '@salesforce/apex/ContactController.createContact';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
async handleSave() {
try {
await createContact({ accountId: this.recordId, lastName: this.lastName });
this.dispatchEvent(new ShowToastEvent({
title: 'Success', message: 'Contact created', variant: 'success'
}));
} catch (error) {
this.dispatchEvent(new ShowToastEvent({
title: 'Error creating contact',
message: error.body?.message,
variant: 'error'
}));
}
}
@salesforce/apex and pass it the wire's provisioned value.6 Custom Events & Component Communication
How components talk to each other depends entirely on their relationship in the component tree.
Parent → child: @api properties
The parent sets an @api property directly as an HTML attribute in its own template — no events needed for this direction.
<template>
<c-child-badge label="VIP Customer"></c-child-badge>
</template>
Child → parent: CustomEvent
The child creates and dispatches a CustomEvent, optionally carrying a payload in detail. The parent listens using an on-prefixed attribute matching the event's name.
handleSelect(event) {
const selectedEvent = new CustomEvent('select', {
detail: { contactId: this.contact.Id }
});
this.dispatchEvent(selectedEvent);
}
<c-contact-row onselect={handleContactSelect}></c-contact-row>
handleContactSelect(event) {
const contactId = event.detail.contactId;
}
Unrelated components: Lightning Message Service
LWC events do not bubble across the synthetic Shadow DOM boundary by default, so two sibling components — or components on entirely different parts of a page — cannot communicate with plain DOM events. For that, use Lightning Message Service (LMS): define a Message Channel metadata component, then publish()/subscribe() to it from any component regardless of where it sits in the DOM tree.
7 Lightning Data Service
Lightning Data Service (LDS) lets you read and write records directly from LWC without writing any Apex at all — useful for simple CRUD against standard or custom objects, and it comes with automatic caching, field-level security enforcement, and cross-component cache sharing built in.
| Module | Function | Use case |
|---|---|---|
lightning/uiRecordApi | getRecord | Read one or more fields from a specific record, wired reactively. |
lightning/uiRecordApi | createRecord | Insert a new record imperatively, given an object API name and field values. |
lightning/uiRecordApi | updateRecord | Update an existing record imperatively — automatically refreshes LDS's shared cache for every component viewing that record. |
lightning/uiRecordApi | deleteRecord | Delete a record imperatively. |
lightning/uiObjectInfoApi | getObjectInfo | Read object-level metadata — picklist values, required fields, record type Ids. |
updateRecord automatically refreshes every other LDS-backed component showing that record — no manual event wiring required.8 Common LWC Mistakes
These mistakes account for the majority of LWC bugs and App Builder confusion reported by developers new to the framework.
Missing $ prefix on reactive wire parameters
Writing { recordId: recordId } instead of { recordId: '$recordId' } passes a one-time literal value instead of a reactive reference — the wire never re-fires when this.recordId changes.
Forgetting isExposed in js-meta.xml
A perfectly working component silently fails to appear in Lightning App Builder's component list because <isExposed>true</isExposed> was never set.
Unconditional mutation in renderedCallback
Setting reactive state unconditionally inside renderedCallback() triggers a re-render, which re-runs the callback, which mutates state again — an infinite loop. Always guard with a one-time flag.
Using @wire for a write operation
@wire is for reads only. A method annotated cacheable=true that performs DML will fail deployment — Salesforce explicitly disallows marking a DML-performing method as cacheable.
Expecting events to bubble across components
LWC's synthetic Shadow DOM does not bubble custom events past the immediate parent by default. Sibling or distant-ancestor communication needs Lightning Message Service, not a plain dispatchEvent.
Not handling the wire's error state
Only checking data and ignoring error in a wire's function form means a failed query (e.g. a field-level security block) fails silently, leaving the UI stuck in a loading state with no feedback.
Practice LWC on ApexArena
Reading about decorators and wire adapters only gets you so far. Build and test real components in the browser-based LWC Playground — no Salesforce org required.
Practice: Wired Contact List
Build a component that wires an Apex method to display an Account's Contacts reactively, re-fetching whenever the record changes.
Start challenge →Practice: Parent-Child Selection
Implement a child row component that dispatches a custom event on click, and a parent that listens and highlights the selected row.
Start challenge →Practice: Create Record with Toast Feedback
Call an Apex method imperatively from a button handler, and show a success or error toast using ShowToastEvent based on the result.
Start challenge →Practice: Inline Edit with updateRecord
Build a component that reads a field with getRecord and writes changes back with updateRecord, without any Apex code.
Start challenge →Frequently Asked Questions
@api marks a property or method as public, exposing it so a parent component (or Lightning App Builder) can set it or call it. @track was required in early LWC versions to make object/array mutations reactive, but since Spring '20 all fields are reactive by default and @track is rarely needed except for deep mutation of objects/arrays in place. @wire connects a component property or function to a Salesforce data source — a Lightning Data Service wire adapter or an Apex method — automatically re-invoking it and pushing fresh data whenever the underlying reactive parameters change, without you writing any subscription or callback code.
@AuraEnabled(cacheable=true) in Apex, and use @wire(apexMethod, { params }) on a property or function — LWC calls it automatically and re-calls it if the parameters change, with built-in client-side caching. For imperative calls (needed for anything that writes data, i.e. not cacheable), import the same method and call it directly as a Promise inside a JavaScript function, typically from a button click handler, using .then()/.catch() or async/await.
@api public properties set directly in the parent's HTML template. Child-to-parent communication uses custom events: the child creates a new CustomEvent with an optional detail payload and calls this.dispatchEvent(event), and the parent listens for it using an on-prefixed attribute in its template (e.g. onselect for a "select" event). Events do not bubble by default in LWC's synthetic Shadow DOM, so cross-component communication beyond direct parent-child typically uses a Lightning Message Service channel or a shared Apex/wire data source instead.