sf-lwc-development

Covers building Lightning Web Components on the Salesforce platform - bundle anatomy and js-meta.xml targets/targetConfigs, the reactivity model, @api/@wire/@track decorators, Lightning Data Service a

SKILL.md

GitHub

Lightning Web Components Development

When to use

Situation Use this skill
Creating or editing anything under force-app/main/default/lwc/ Yes
Choosing between @wire, imperative Apex, and Lightning Data Service Yes
Component must appear in Lightning App Builder, Flow Builder, or Experience Builder Yes
Writing the Apex controller behind the component Skill sf-apex-development (this skill covers only the JS side of the contract)
Writing Jest tests for the component Skill sf-lwc-jest-testing
Previewing the component without deploying Skill sf-local-development
ESLint/PMD/Code Analyzer findings on LWC JS Skill sf-code-analyzer-quality
Generating a component, spec or migration with Salesforce DX MCP tools or Agentforce Vibes LWC skills Yes - references/mcp-and-skills.md, then the same gates apply

LWC is the owner slice of sf-lwc-engineer in wave 1: lwc/ and aura/ only. Apex signatures the component consumes are fixed in wave 0 and recorded in .vibeforce/state/contract.md.

Decision table

Need Mechanism Cacheable Refresh path
Read one record, fields known at build time @wire(getRecord, {recordId, fields}) Yes (LDS) Automatic; notifyRecordUpdateAvailable() after out-of-band writes
Read several related objects in one round trip @wire(graphql, {query, variables}) Yes (LDS) refreshGraphQL / re-evaluate variables
Read object metadata or picklists getObjectInfo, getPicklistValues Yes (LDS) Automatic
Read a query/aggregate LDS cannot express @wire(apexMethod) with @AuraEnabled(cacheable=true) Yes (client cache) refreshApex(this.wiredResult)
Write one record, standard fields createRecord / updateRecord / deleteRecord (lightning/uiRecordApi) n/a LDS updates wires automatically
Write with server-side business logic imperative Apex (@AuraEnabled, never cacheable=true) No await notifyRecordUpdateAvailable([{recordId}])
Form UI with zero JS lightning-record-form / lightning-record-edit-form n/a onsuccess event
Fire-and-forget action on user click imperative Apex in an async handler No n/a

Rule: a method annotated @AuraEnabled(cacheable=true) cannot perform DML; it is the only shape @wire accepts. Any DML path must be a separate non-cacheable @AuraEnabled method.

Core patterns

1. Bundle anatomy and configuration

force-app/main/default/lwc/accountCard/
  accountCard.js          default-exported class extending LightningElement
  accountCard.html        template (optional only for service components)
  accountCard.css         scoped styles (optional)
  accountCard.js-meta.xml required; controls exposure and builder configuration
  __tests__/accountCard.test.js   Jest suite (gate: vf-check jest)
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>67.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Account Card</masterLabel>
    <description>Shows key account fields with an escalate action.</description>
    <targets>
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightningCommunity__Default</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordPage">
            <property name="showEscalate" type="Boolean" label="Show Escalate Button" default="true"/>
            <objects>
                <object>Account</object>
            </objects>
        </targetConfig>
        <targetConfig targets="lightningCommunity__Default">
            <property name="recordId" type="String" label="Record Id"/>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

apiVersion comes from config/vibe-force.defaults.json (67.0). Beginning in Spring '25 every component must carry an apiVersion tag to be saved back to Salesforce. isExposed=false hides the component from all builders; exposure additionally requires at least one <target>. Full target list: references/component-reference.md.

2. Reactivity: fields, not state objects

import { LightningElement, track } from 'lwc';

export default class ReactivityExample extends LightningElement {
    // Reactive by default: reassignment of a field re-renders if the field is used in the template
    count = 0;
    filters = { status: 'Open' };
    @track rows = [];          // @track only for mutation-in-place of objects/arrays

    get label() {              // getters are the supported way to compute template values
        return `${this.count} open`;
    }

    mutate() {
        this.count = 1;                               // rerender
        this.filters.status = 'Closed';               // NO rerender: field not reassigned
        this.filters = { ...this.filters, status: 'Closed' }; // rerender
        this.rows.push({ id: '1' });                  // rerenders only because of @track
    }
}

Reassigning a field to an ===-equal primitive is not a mutation and does not rerender. Prefer immutable replacement ({...obj}, [...arr]) over @track; reserve @track for deep mutation you genuinely cannot avoid. @track and @api on the same property is a compile error (LWC1093).

3. Public API: @api properties and methods

import { LightningElement, api } from 'lwc';

export default class Badge extends LightningElement {
    @api recordId;            // set by the record page container
    @api variant = 'neutral';

    _rows = [];
    @api
    get rows() { return this._rows; }
    set rows(value) { this._rows = Array.isArray(value) ? [...value] : []; } // normalize on the way in

    @api focusFirst() {       // public method, called by parent via querySelector
        this.refs.firstInput?.focus();
    }
}

Never mutate an @api property from inside the component; the owner owns the value. Public property setters run before connectedCallback() on initial render and again on every reassignment by the owner.

4. Wire service and refresh

import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import RATING_FIELD from '@salesforce/schema/Account.Rating';
import getOpenCases from '@salesforce/apex/AccountCaseController.getOpenCases';
import { refreshApex } from '@salesforce/apex';

export default class AccountCard extends LightningElement {
    @api recordId;
    wiredCases;
    error;

    @wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, RATING_FIELD] })
    account;

    @wire(getOpenCases, { accountId: '$recordId' })
    handleCases(result) {
        this.wiredCases = result;            // keep the whole result for refreshApex
        const { data, error } = result;
        this.error = error ? this.reduce(error) : undefined;
    }

    get accountName() {
        return getFieldValue(this.account.data, NAME_FIELD);
    }

    async refresh() {
        await refreshApex(this.wiredCases);
    }

    reduce(error) {
        return error?.body?.message ?? error?.body?.pageErrors?.[0]?.message ?? 'Unknown error';
    }
}

$recordId makes the configuration dynamic: when recordId changes, the adapter re-provisions. refreshApex takes the value provisioned by an Apex @wire (the whole result object, not result.data). Never mutate a wire configuration property inside renderedCallback(); that is an infinite-loop generator.

5. Imperative Apex and error shape

import escalate from '@salesforce/apex/CaseEscalationService.escalate';
import { notifyRecordUpdateAvailable } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

async handleEscalate() {
    this.busy = true;
    try {
        await escalate({ caseId: this.recordId, reason: this.reason });
        await notifyRecordUpdateAvailable([{ recordId: this.recordId }]);
        this.dispatchEvent(new ShowToastEvent({
            title: 'Escalated', message: 'Case escalated to tier 2.', variant: 'success'
        }));
    } catch (error) {
        this.dispatchEvent(new ShowToastEvent({
            title: 'Escalation failed', message: this.reduce(error), variant: 'error', mode: 'sticky'
        }));
    } finally {
        this.busy = false;
    }
}

Apex errors reach JavaScript as { body: { message } } for AuraHandledException, and as { body: { pageErrors: [], fieldErrors: {}, output: {...} } } for DML/record errors from LDS. Always reduce defensively - see references/data-access-patterns.md for a complete reduceErrors helper.

6. Events: child to parent, and cross-tree

// child: selector.js
handleSelect(event) {
    this.dispatchEvent(new CustomEvent('rowselect', {
        detail: { recordId: event.currentTarget.dataset.id }   // primitives only
    }));
}
<!-- parent -->
<c-selector onrowselect={handleRowSelect}></c-selector>

Defaults are bubbles: false, composed: false: the event stops at the component boundary, which is what you want for parent/child. Use { bubbles: true, composed: true } only for genuinely application-level events, and never put mutable objects in detail. For sibling or cross-DOM communication use Lightning Message Service, not a home-grown pubsub module:

import { LightningElement, wire } from 'lwc';
import { publish, subscribe, unsubscribe, MessageContext, APPLICATION_SCOPE } from 'lightning/messageService';
import RECORD_SELECTED from '@salesforce/messageChannel/Record_Selected__c';

export default class ListPanel extends LightningElement {
    @wire(MessageContext) messageContext;
    subscription;

    connectedCallback() {
        this.subscription = subscribe(this.messageContext, RECORD_SELECTED,
            (message) => { this.selectedId = message.recordId; },
            { scope: APPLICATION_SCOPE });
    }
    disconnectedCallback() { unsubscribe(this.subscription); this.subscription = null; }
    select(id) { publish(this.messageContext, RECORD_SELECTED, { recordId: id }); }
}

7. Lifecycle hooks

Hook Fires Safe to do Never do
constructor() Once, before insertion Call super() first, set non-DOM defaults Touch this.template, read @api values
connectedCallback() On DOM insertion, parent to child Subscribe (LMS, message bus), read @api props, kick off imperative calls Assume children rendered
render() Before each render Return an imported template for multi-template components Side effects
renderedCallback() After every render, child to parent One-time third-party init guarded by a boolean, imperative DOM reads Mutate @api/wire-config props (infinite loop)
disconnectedCallback() On DOM removal unsubscribe, clearInterval, abort fetches Assume it runs on tab close
errorCallback(error, stack) Child throws in lifecycle/render Render a fallback, log Swallow silently

8. Templates

<template>
    <template lwc:if={hasRows}>
        <ul>
            <template for:each={rows} for:item="row" for:index="i">
                <li key={row.id} data-id={row.id} onclick={handleSelect}>{row.name}</li>
            </template>
        </ul>
    </template>
    <template lwc:elseif={loading}><lightning-spinner alternative-text="Loading"></lightning-spinner></template>
    <template lwc:else><p>No results.</p></template>

    <ul>
        <template iterator:it={rows}>
            <li key={it.value.id} class={it.first}>{it.value.name}</li>
        </template>
    </ul>

    <input lwc:ref="firstInput" type="text" />
    <div lwc:dom="manual" class="chart"></div>
    <slot name="footer"></slot>
</template>

lwc:if/lwc:elseif/lwc:else supersede if:true/if:false; they cannot be combined on the same element, and lwc:elseif/lwc:else must immediately follow their sibling. Expressions support only simple dot notation - anything else goes in a getter. key is required on the first element inside for:each/iterator: and must be a stable business id, never the index. Access refs with this.refs.firstInput (do not use lwc:ref inside for:each).

9. Styling, shadow DOM, and light DOM

:host {
    --slds-c-card-color-background: var(--slds-g-color-surface-1);
    display: block;
}
:host(.compact) .row { padding: 0; }
lightning-input { --slds-c-input-color-border: var(--slds-g-color-border-accent-1); }

Style with SLDS utility classes and SLDS styling hooks (CSS custom properties); do not reach across the shadow boundary with descendant selectors - it does not work and will not be supported. Opt into light DOM only when a third-party library or global stylesheet must see your markup:

export default class LightPanel extends LightningElement {
    static renderMode = 'light';   // template must declare <template lwc:render-mode="light">
    renderedCallback() { this.querySelector('.chart'); } // light DOM: no shadowRoot
}

Light DOM gives up style scoping and event retargeting - state the reason in a code comment.

10. Navigation, toasts, labels, static resources

import { NavigationMixin } from 'lightning/navigation';
import { loadScript, loadStyle } from 'lightning/platformResourceLoader';
import CHARTJS from '@salesforce/resourceUrl/chartjs';
import LABEL_ESCALATE from '@salesforce/label/c.Escalate_Case';
import LANG from '@salesforce/i18n/lang';

export default class Panel extends NavigationMixin(LightningElement) {
    label = { escalate: LABEL_ESCALATE };
    lang = LANG;
    chartInitialized = false;

    goToRecord(recordId) {
        this[NavigationMixin.Navigate]({
            type: 'standard__recordPage',
            attributes: { recordId, objectApiName: 'Case', actionName: 'view' }
        });
    }

    async renderedCallback() {
        if (this.chartInitialized) return;
        this.chartInitialized = true;
        await loadStyle(this, `${CHARTJS}/chart.css`);
        await loadScript(this, `${CHARTJS}/chart.umd.js`);
        this.renderChart();
    }
}

Never hardcode user-facing strings: import custom labels via @salesforce/label/c.Name and locale data via @salesforce/i18n/*. Third-party JS must live in a static resource and load through lightning/platformResourceLoader; CDN script tags are blocked by CSP.

Anti-patterns

Anti-pattern Why it fails Fix
this.filters.status = 'x' on an untracked object and expecting a rerender Field was not reassigned; no mutation detected this.filters = { ...this.filters, status: 'x' }
@track on every field Deep proxying cost with no benefit; fields are already reactive Remove; use @track only for in-place mutation
Setting a wired config property inside renderedCallback() Re-provision triggers rerender triggers hook: infinite loop Set it in connectedCallback() or a handler
@AuraEnabled(cacheable=true) on a method that performs DML Runtime error; cacheable methods cannot write Split into a cacheable read and a non-cacheable write
Calling an imperative Apex writer and expecting @wire(getRecord) to update LDS does not know the record changed await notifyRecordUpdateAvailable([{recordId}])
new CustomEvent('select', { bubbles: true, composed: true }) for a parent handler Leaks the event to the whole page; breaks encapsulation Default (bubbles: false) and handle on the child tag
Custom pubsub.js module for sibling communication Not supported across DOM trees or Aura/Visualforce; leaks subscriptions Lightning Message Service with MessageContext and unsubscribe
element.shadowRoot.querySelector from a parent component Breaks encapsulation; blocked/locked at runtime @api method or event
key={index} in for:each Diffing reuses the wrong nodes on reorder key={row.id}
console.log left in shipped components Leaks data to the browser console in production Remove; vf-check lint flags no-console
Building a form with 12 lightning-input fields plus manual DML Reimplements field-level security, layouts, and validation lightning-record-edit-form or lightning-record-form
CDN <script src> or inline <script> Blocked by CSP and Lightning Web Security Static resource + loadScript

Verification

# LWC unit tests and coverage (no org needed)
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" jest --changed

# Full local gate: prettier + eslint + Code Analyzer + jest
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed

# Live preview of the component against a scratch org (no deploy)
sf lightning dev component --name accountCard --target-org vf-dev

# Validate the bundle compiles server-side before deploying
sf project deploy start --source-dir force-app/main/default/lwc/accountCard \
  --dry-run --target-org vf-dev

# Deploy, then verify in the org
sf project deploy start --source-dir force-app/main/default/lwc/accountCard --target-org vf-dev
sf org open --path lightning/n/Account_Console --target-org vf-dev

Gates that apply to LWC work: requireJestForLwc (every bundle needs __tests__), jestCoverageMin 80, and analyzerFailSeverity 3. See skills sf-lwc-jest-testing, sf-code-analyzer-quality, and sf-post-deploy-verification.

References