ApexSensei

Blog

Lightning Web Components (LWC) Beginner Guide: Build Your First Component in 30 Minutes

By Firus Hanov ยท ยท 11 min read

Complete beginner guide to Lightning Web Components. Learn LWC file structure, data binding, @wire, imperative Apex, component communication, and build a real Contact Card component.

Lightning Web Components (LWC) Beginner Guide: Build Your First Component in 30 Minutes

Lightning Web Components (LWC) is Salesforce's modern UI framework, and if you've been building in Salesforce for more than a year, you need to know it. Whether you're coming from Aura, Visualforce, or starting fresh โ€” LWC is where Salesforce UI development lives today.

The good news: LWC is built on modern web standards. If you know JavaScript, HTML, and CSS, you already know a lot of what makes LWC work. The Salesforce-specific parts are actually pretty small.

By the end of this guide, you'll understand how LWC components are structured, how data flows between components, how to call Apex from LWC, and you'll have built your first real component. ๐Ÿ”ฅ


What is Lightning Web Components?

LWC is a component-based framework that Salesforce introduced in 2019 to replace the older Aura (Lightning) framework. It's built on web standards โ€” Custom Elements, Shadow DOM, and ES Modules โ€” rather than proprietary abstractions.

This matters because:

  • Better performance: Web standards are optimized by browsers natively
  • Smaller learning curve: Standard JavaScript patterns, no framework-specific oddities
  • Easier testing: Standard JavaScript testing tools work out of the box
  • Future-proof: As web standards evolve, LWC evolves with them

LWC is used for record detail pages, app pages, utility bars, custom tabs, communities, email templates โ€” essentially anywhere you need a custom UI in Salesforce.


LWC File Structure

Every LWC component lives in its own folder. The folder name becomes the component's API name.

myComponent/
โ”œโ”€โ”€ myComponent.html          โ† Template (required)
โ”œโ”€โ”€ myComponent.js            โ† Controller logic (required)
โ”œโ”€โ”€ myComponent.css           โ† Styles (optional)
โ”œโ”€โ”€ myComponent.js-meta.xml   โ† Metadata (required for deployment)
โ””โ”€โ”€ __tests__/
    โ””โ”€โ”€ myComponent.test.js   โ† Jest tests (recommended)

Let's look at each file.


The HTML Template

<!-- myComponent.html -->
<template>
    <lightning-card title="My First Component" icon-name="standard:account">
        <div class="slds-m-around_medium">
            <p>Hello, {greeting}!</p>
            <lightning-button
                label="Say Hello"
                onclick={handleClick}>
            </lightning-button>
        </div>
    </lightning-card>
</template>

Key rules for LWC templates:

  • Must have a single <template> root element
  • Data binding uses {propertyName} โ€” single curly braces, NOT double like Angular/Vue
  • Event handlers reference a method with {methodName} syntax
  • Conditional rendering uses lwc:if, lwc:elseif, lwc:else
  • List iteration uses for:each and for:item

The JavaScript Controller

// myComponent.js
import { LightningElement, track } from 'lwc';

export default class MyComponent extends LightningElement {
    greeting = 'World';

    handleClick() {
        this.greeting = 'Salesforce Developer';
    }
}

Important concepts:

  • Your class must extend LightningElement
  • Properties bound in the template are reactive by default in modern LWC
  • @api makes a property public (settable by parent components)
  • @track is no longer needed for most reactive properties (it's the default now)

The CSS File

/* myComponent.css */
.greeting-text {
    font-weight: bold;
    color: #1b5e20;
}

LWC CSS is automatically scoped to the component via Shadow DOM. Your styles won't accidentally affect other components, and external styles won't bleed in.


The Metadata File

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>59.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__RecordPage</target>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>

isExposed: true makes the component available in Lightning App Builder. The targets define where it can be placed.


Your First Real Component: Contact Card

Let's build something useful โ€” a component that displays a contact's details and lets you update their title.

The Apex Class

public with sharing class ContactController {

    @AuraEnabled(cacheable=true)
    public static Contact getContact(Id contactId) {
        return [
            SELECT Id, FirstName, LastName, Title, Email, Phone, AccountId,
                   Account.Name
            FROM Contact
            WHERE Id = :contactId
            WITH SECURITY_ENFORCED
        ];
    }

    @AuraEnabled
    public static void updateContactTitle(Id contactId, String newTitle) {
        Contact c = new Contact(Id = contactId, Title = newTitle);
        update c;
    }
}

Key points:

  • @AuraEnabled makes the method callable from LWC
  • cacheable=true enables client-side caching (use for read-only queries)
  • with sharing enforces the user's sharing rules
  • WITH SECURITY_ENFORCED enforces field-level security at the query level

The Component HTML

<template>
    <lightning-card title="Contact Details" icon-name="standard:contact">
        <template lwc:if={isLoading}>
            <lightning-spinner alternative-text="Loading" size="small"></lightning-spinner>
        </template>
        <template lwc:elseif={error}>
            <p class="slds-text-color_error">Error loading contact: {error}</p>
        </template>
        <template lwc:elseif={contact}>
            <div class="slds-m-around_medium">
                <p class="slds-text-heading_medium">
                    {contact.FirstName} {contact.LastName}
                </p>
                <p>{contact.Email}</p>
                <p>{contact.Phone}</p>
                <div class="slds-m-top_medium">
                    <lightning-input label="Update Title" value={newTitle} onchange={handleTitleChange}></lightning-input>
                    <lightning-button label="Save Title" variant="brand" onclick={handleSave} class="slds-m-top_small"></lightning-button>
                </div>
            </div>
        </template>
    </lightning-card>
</template>

The Component JavaScript

import { LightningElement, api, wire } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { refreshApex } from '@salesforce/apex';
import getContact from '@salesforce/apex/ContactController.getContact';
import updateContactTitle from '@salesforce/apex/ContactController.updateContactTitle';

export default class ContactCard extends LightningElement {
    @api recordId;
    contact;
    error;
    newTitle = '';
    isLoading = false;
    wiredContactResult;

    @wire(getContact, { contactId: '$recordId' })
    wiredContact(result) {
        this.wiredContactResult = result;
        if (result.data) {
            this.contact = result.data;
            this.newTitle = result.data.Title || '';
            this.error = undefined;
        } else if (result.error) {
            this.error = result.error.body?.message || 'Unknown error';
            this.contact = undefined;
        }
    }

    handleTitleChange(event) {
        this.newTitle = event.target.value;
    }

    async handleSave() {
        this.isLoading = true;
        try {
            await updateContactTitle({ contactId: this.recordId, newTitle: this.newTitle });
            this.dispatchEvent(new ShowToastEvent({ title: 'Success', message: 'Contact title updated!', variant: 'success' }));
            await refreshApex(this.wiredContactResult);
        } catch (error) {
            this.dispatchEvent(new ShowToastEvent({ title: 'Error', message: error.body?.message || 'Update failed', variant: 'error' }));
        } finally {
            this.isLoading = false;
        }
    }
}

This component demonstrates:

  • @api recordId โ€” receives the current record's ID automatically
  • @wire โ€” reactive data fetching that auto-updates
  • async/await โ€” clean async pattern for imperative Apex calls
  • ShowToastEvent โ€” standard success/error notifications
  • refreshApex โ€” re-fetches wire data after a DML operation

Understanding @wire vs Imperative Apex

@wire โ€” Declarative, Reactive

@wire(getContact, { contactId: '$recordId' })
wiredContact({ data, error }) {
    // Auto-runs when contactId changes
    // Auto-caches results
}

Use @wire when:

  • You want data loaded automatically on component render
  • The data is read-only
  • You want automatic reactivity to property changes

Imperative โ€” Manual, Controlled

async handleButtonClick() {
    try {
        const result = await myApexMethod({ param: this.someValue });
        this.data = result;
    } catch (error) {
        // Handle error
    }
}

Use imperative when:

  • You need the call triggered by a user action
  • The Apex method has side effects (DML)
  • You need precise control over when the call happens

Component Communication

Parent to Child: Properties

<c-child-component greeting="Hello from Parent" record-id={currentId}></c-child-component>
import { LightningElement, api } from 'lwc';
export default class ChildComponent extends LightningElement {
    @api greeting;
    @api recordId;
}

Child to Parent: Custom Events

// Child fires event
handleButtonClick() {
    this.dispatchEvent(new CustomEvent('itemselected', {
        detail: { selectedId: this.itemId }
    }));
}

// Parent handles event
handleItemSelected(event) {
    const selectedId = event.detail.selectedId;
}

Conditional Rendering and Lists

<template>
    <template lwc:if={showDetails}>
        <p>Details are visible</p>
    </template>
    <template lwc:else>
        <p>Details are hidden</p>
    </template>

    <template for:each={items} for:item="item">
        <div key={item.id}>{item.name}</div>
    </template>
</template>

Note: The key attribute is required on the immediate child of for:each.


Quick Reference: Essential LWC Imports

import { LightningElement, api, wire, track } from 'lwc';
import myMethod from '@salesforce/apex/MyClass.myMethod';
import userId from '@salesforce/user/Id';
import myLabel from '@salesforce/label/c.My_Custom_Label';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { getRecord, updateRecord } from 'lightning/uiRecordApi';
import { refreshApex } from '@salesforce/apex';
import { NavigationMixin } from 'lightning/navigation';
import NAME_FIELD from '@salesforce/schema/Contact.Name';

Next Steps

You now understand the fundamentals of LWC: file structure, properties, wire service, imperative Apex, component communication, and conditional rendering. That's enough to build real, production-ready components.

To level up from here:

  1. Lightning Data Service (LDS): Use lightning-record-form for standard record operations without Apex
  2. Navigation: NavigationMixin for navigating between records and pages
  3. Lightning Message Service (LMS): Communication between components that don't share a parent
  4. Jest Testing: Unit test your LWC JavaScript logic without deploying

Want to go deeper on LWC? Book a hands-on session with ApexSensei and I'll walk you through building a real-world LWC component for your specific use case. Start practicing on ApexSensei โ†’

What's your first LWC project going to be? Tell me in the comments and I'll give you pointers. ๐Ÿ’ฌ

Practice Apex free on ApexSensei