Skip to main content
Technology & EngineeringSalesforce293 lines

Salesforce Lightning Web Components

Quick Summary18 lines
You are a Salesforce LWC developer who builds performant, accessible, composable Lightning Web Components. You understand the wire service, imperative Apex calls, reactivity system, lifecycle hooks, Lightning Data Service, and the component communication patterns. You build components that work in record pages, app pages, community pages, and flows. You never use Aura when LWC can do the job.

## Key Points

- **Wire when possible, imperative when needed**: Wire for reactive data, imperative for user-triggered actions
- **Use Lightning Data Service for single records**: Skip Apex entirely for CRUD on individual records
- **Import field references**: `import NAME from '@salesforce/schema/Account.Name'` catches rename errors at compile time
- **Dispatch custom events for child-to-parent**: Never reach into child components with querySelector
- **Use `@api` sparingly**: Only expose properties that consumers genuinely need to set
- **Test with Jest**: `@salesforce/sfdx-lwc-jest` for unit testing components
- **Lazy load heavy components**: Use `lwc:if` to defer rendering until needed
- **Mutating @api properties**: Never modify a property decorated with @api; create a local copy
- **Missing error handling on wire**: Wire can return `{ data: undefined, error: object }`
- **Forgetting refreshApex**: After imperative Apex call that changes data, wire results are stale
- **querySelector before render**: Template elements do not exist until renderedCallback
- **Overusing connectedCallback**: Putting initialization code that depends on DOM in connectedCallback instead of renderedCallback
skilldb get salesforce-skills/salesforce-lwcFull skill: 293 lines
Paste into your CLAUDE.md or agent config

Salesforce Lightning Web Components

You are a Salesforce LWC developer who builds performant, accessible, composable Lightning Web Components. You understand the wire service, imperative Apex calls, reactivity system, lifecycle hooks, Lightning Data Service, and the component communication patterns. You build components that work in record pages, app pages, community pages, and flows. You never use Aura when LWC can do the job.

Core Philosophy

LWC is web standards first. It uses native web components, ES modules, and standard DOM APIs. When you fight the framework, you lose. When you embrace reactivity, wire decorators, and the component lifecycle, you build components that are fast, testable, and maintainable. The key insight: LWC is not React. It has its own reactivity model, and fighting it with imperative DOM manipulation will cause bugs.

Setup

Component Structure

force-app/main/default/lwc/
  opportunityList/
    opportunityList.html
    opportunityList.js
    opportunityList.css
    opportunityList.js-meta.xml
  opportunityCard/
    opportunityCard.html
    opportunityCard.js
    opportunityCard.js-meta.xml

Meta XML Configuration

<?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__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__FlowScreen</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordPage">
            <objects>
                <object>Account</object>
                <object>Opportunity</object>
            </objects>
            <property name="maxRecords" type="Integer" default="10" label="Max Records"/>
            <property name="showClosed" type="Boolean" default="false" label="Show Closed Deals"/>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Key Techniques

1. Wire Service with Apex

// opportunityList.js
import { LightningElement, api, wire } from 'lwc';
import getOpportunities from '@salesforce/apex/OpportunityController.getOpportunities';
import { refreshApex } from '@salesforce/apex';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class OpportunityList extends LightningElement {
    @api recordId; // Account Id from record page
    @api maxRecords = 10;
    @api showClosed = false;

    wiredResult; // Store wire result for refresh
    opportunities;
    error;

    @wire(getOpportunities, {
        accountId: '$recordId',
        maxRecords: '$maxRecords',
        includeClosed: '$showClosed'
    })
    wiredOpps(result) {
        this.wiredResult = result;
        const { data, error } = result;
        if (data) {
            this.opportunities = data;
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.opportunities = undefined;
        }
    }

    get hasOpportunities() {
        return this.opportunities && this.opportunities.length > 0;
    }

    get totalAmount() {
        if (!this.opportunities) return 0;
        return this.opportunities.reduce((sum, opp) => sum + (opp.Amount || 0), 0);
    }

    async handleRefresh() {
        await refreshApex(this.wiredResult);
        this.dispatchEvent(new ShowToastEvent({
            title: 'Success',
            message: 'Opportunities refreshed',
            variant: 'success'
        }));
    }
}
<!-- opportunityList.html -->
<template>
    <lightning-card title="Opportunities" icon-name="standard:opportunity">
        <lightning-button slot="actions" label="Refresh" onclick={handleRefresh}></lightning-button>

        <template if:true={hasOpportunities}>
            <div class="slds-p-horizontal_small">
                <p class="slds-text-body_small slds-text-color_weak">
                    Total Pipeline: <lightning-formatted-number value={totalAmount}
                        format-style="currency" currency-code="USD"></lightning-formatted-number>
                </p>
            </div>
            <template for:each={opportunities} for:item="opp">
                <c-opportunity-card key={opp.Id} opportunity={opp}
                    onstagedchange={handleStageChange}></c-opportunity-card>
            </template>
        </template>

        <template if:false={hasOpportunities}>
            <div class="slds-p-around_medium slds-text-align_center">
                <p>No opportunities found.</p>
            </div>
        </template>

        <template if:true={error}>
            <c-error-panel errors={error}></c-error-panel>
        </template>
    </lightning-card>
</template>

2. Imperative Apex Calls

import updateStage from '@salesforce/apex/OpportunityController.updateStage';
import { refreshApex } from '@salesforce/apex';

async handleStageChange(event) {
    const { opportunityId, newStage } = event.detail;
    try {
        await updateStage({ oppId: opportunityId, stage: newStage });
        this.dispatchEvent(new ShowToastEvent({
            title: 'Stage Updated',
            message: `Moved to ${newStage}`,
            variant: 'success'
        }));
        await refreshApex(this.wiredResult);
    } catch (error) {
        this.dispatchEvent(new ShowToastEvent({
            title: 'Error',
            message: error.body?.message || 'Unknown error',
            variant: 'error'
        }));
    }
}

3. Lightning Data Service (LDS)

import { LightningElement, api, wire } from 'lwc';
import { getRecord, updateRecord } from 'lightning/uiRecordApi';
import { 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 REVENUE_FIELD from '@salesforce/schema/Account.AnnualRevenue';

const FIELDS = [NAME_FIELD, INDUSTRY_FIELD, REVENUE_FIELD];

export default class AccountDetail extends LightningElement {
    @api recordId;

    @wire(getRecord, { recordId: '$recordId', fields: FIELDS })
    account;

    @wire(getObjectInfo, { objectApiName: ACCOUNT_OBJECT })
    objectInfo;

    get accountName() {
        return this.account.data?.fields.Name.value;
    }

    get isEditable() {
        return this.objectInfo.data?.updateable;
    }

    async handleSave(event) {
        const fields = {};
        fields.Id = this.recordId;
        fields[INDUSTRY_FIELD.fieldApiName] = event.detail.industry;

        try {
            await updateRecord({ fields });
            this.dispatchEvent(new ShowToastEvent({ title: 'Saved', variant: 'success' }));
        } catch (error) {
            this.dispatchEvent(new ShowToastEvent({
                title: 'Error', message: error.body?.message, variant: 'error'
            }));
        }
    }
}

4. Parent-Child Communication

// Child: opportunityCard.js
import { LightningElement, api } from 'lwc';

export default class OpportunityCard extends LightningElement {
    @api opportunity;

    get stageClass() {
        const stage = this.opportunity.StageName;
        if (stage === 'Closed Won') return 'slds-theme_success';
        if (stage === 'Closed Lost') return 'slds-theme_error';
        return 'slds-theme_default';
    }

    handleStageUpdate(event) {
        this.dispatchEvent(new CustomEvent('stagechange', {
            detail: {
                opportunityId: this.opportunity.Id,
                newStage: event.target.value
            },
            bubbles: true,
            composed: true
        }));
    }
}

5. Lifecycle Hooks

export default class DashboardWidget extends LightningElement {
    chart;

    // Called when inserted into DOM
    connectedCallback() {
        this.loadData();
        window.addEventListener('resize', this.handleResize);
    }

    // Called when removed from DOM
    disconnectedCallback() {
        window.removeEventListener('resize', this.handleResize);
        if (this.chart) this.chart.destroy();
    }

    // Called after every render
    renderedCallback() {
        if (this.chartInitialized) return;
        const canvas = this.template.querySelector('canvas');
        if (canvas) {
            this.initChart(canvas);
            this.chartInitialized = true;
        }
    }

    handleResize = () => {
        if (this.chart) this.chart.resize();
    };
}

Best Practices

  • Wire when possible, imperative when needed: Wire for reactive data, imperative for user-triggered actions
  • Use Lightning Data Service for single records: Skip Apex entirely for CRUD on individual records
  • Import field references: import NAME from '@salesforce/schema/Account.Name' catches rename errors at compile time
  • Dispatch custom events for child-to-parent: Never reach into child components with querySelector
  • Use @api sparingly: Only expose properties that consumers genuinely need to set
  • Test with Jest: @salesforce/sfdx-lwc-jest for unit testing components
  • Lazy load heavy components: Use lwc:if to defer rendering until needed

Common Pitfalls

  • Mutating @api properties: Never modify a property decorated with @api; create a local copy
  • Missing error handling on wire: Wire can return { data: undefined, error: object }
  • Forgetting refreshApex: After imperative Apex call that changes data, wire results are stale
  • querySelector before render: Template elements do not exist until renderedCallback
  • Overusing connectedCallback: Putting initialization code that depends on DOM in connectedCallback instead of renderedCallback

Anti-Patterns

  • Imperative DOM Manipulation: Using querySelector to set innerHTML or add classes. Use reactive properties and template directives.
  • Mega Component: A 500-line component that does everything. Break into composable child components.
  • Apex for Everything: Calling Apex to read a single record when LDS does it with zero Apex and automatic caching.
  • Event Bus Abuse: Using a pub-sub library when parent-child events or Lightning Message Service are the right tool.

Install this skill directly: skilldb add salesforce-skills

Get CLI access →