Lightning Web Components Tutorial

Lightning Web Components —
Complete Tutorial & Practice Guide

Everything you need to build production-grade LWCs: component anatomy, decorators, lifecycle hooks, calling Apex, custom events, and Lightning Data Service — explained clearly with code examples.

~17 min read LWC & Apex All experience levels
Advertisement

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.

LWC vs Aura, in one line. Aura is a Salesforce-proprietary framework with its own component model and event system. LWC is standards-based — if you already know modern JavaScript, HTML, and CSS, most of what you know transfers directly, with a thin layer of Salesforce-specific APIs (decorators, wire adapters, and base Lightning components) on top.

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 recordId property.
  • 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.

FileRequired?Purpose
accountCard.htmlYesThe component's markup — an HTML template compiled into the component's shadow DOM.
accountCard.jsYesThe component's JavaScript class, extending LightningElement, holding properties and methods.
accountCard.js-meta.xmlFor most usesConfiguration — which pages/orgs can use it, whether it's exposed, and any design-time properties for App Builder.
accountCard.cssNoScoped CSS — styles here only apply inside this component's shadow DOM, never leaking out or in.
accountCard.svgNoA custom icon shown for the component in Lightning App Builder.
__tests__/accountCard.test.jsNoJest unit tests, run entirely outside a Salesforce org using sfdx-lwc-jest.
HTML — accountCard.html
<template>
    <lightning-card title="Account Details" icon-name="standard:account">
        <div class="slds-p-horizontal_small">
            <p>{accountName}</p>
        </div>
    </lightning-card>
</template>
JavaScript — accountCard.js
import { LightningElement, api } from 'lwc';

export default class AccountCard extends LightningElement {
    @api accountName;
}
Metadata — accountCard.js-meta.xml
<?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>
isExposed must be true for the component to appear in Lightning App Builder or a Flow Screen. It's easy to forget this on a new component and then wonder why it doesn't show up in the builder's component list.

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.

DecoratorPurposeReactive?
@apiExposes a property or method as public — settable by a parent component's template, or by App Builder/Flow as a configurable input.Yes
@trackHistorically 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
@wireConnects 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.

JavaScript — child component exposing an @api property
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.

JavaScript — @wire with a property vs a function
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); }
    }
}
Note the '$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.

HookFires whenCommon 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.
renderedCallback() runs on every render. If you mutate reactive state unconditionally inside it, you can trigger an infinite render loop. Always guard with a boolean flag (e.g. 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.

Apex — cacheable method
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
        ];
    }
}
JavaScript — consuming it with @wire
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.

JavaScript — imperative call from a button handler
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'
        }));
    }
}
refreshApex() lets you manually re-invoke a wired Apex call after an imperative DML operation — for example, refreshing a wired list right after an imperative create — without waiting for the wire's reactive parameters to change. Import it from @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.

HTML — parent passing data down
<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.

JavaScript — child dispatching a custom event
handleSelect(event) {
    const selectedEvent = new CustomEvent('select', {
        detail: { contactId: this.contact.Id }
    });
    this.dispatchEvent(selectedEvent);
}
HTML — parent listening for it
<c-contact-row onselect={handleContactSelect}></c-contact-row>
JavaScript — parent's handler receives the payload
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.

ModuleFunctionUse case
lightning/uiRecordApigetRecordRead one or more fields from a specific record, wired reactively.
lightning/uiRecordApicreateRecordInsert a new record imperatively, given an object API name and field values.
lightning/uiRecordApiupdateRecordUpdate an existing record imperatively — automatically refreshes LDS's shared cache for every component viewing that record.
lightning/uiRecordApideleteRecordDelete a record imperatively.
lightning/uiObjectInfoApigetObjectInfoRead object-level metadata — picklist values, required fields, record type Ids.
Prefer LDS over Apex for straightforward single-record reads/writes. Because every component using LDS for the same record shares one client-side cache, updating a record through 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.

Advertisement

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.

Frequently Asked Questions

What is a Lightning Web Component (LWC)?
A Lightning Web Component is a custom HTML element built using modern JavaScript (ECMAScript 6+) and the Lightning Web Components framework — Salesforce's implementation on top of native Web Components standards (Custom Elements, Shadow DOM, templates, and ES modules). Unlike the older Aura framework, LWC compiles directly to standards-based browser APIs, making it significantly faster and lighter weight. Each component consists of an HTML template, a JavaScript class, and an XML configuration file that controls where the component can be used.
What is the difference between @api, @track, and @wire in LWC?
@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.
How do you call an Apex method from a Lightning Web Component?
There are two ways: wire (declarative, reactive) and imperative (on-demand). For wire, import the Apex method, annotate it @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.
How do child components communicate with parent components in LWC?
Parent-to-child communication uses @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.
Advertisement