agentleFS
grzmolgrzmol/vibe-force/skills/sf-lwc-development/SKILL.md

sf-lwc-development

Skill2 starsChanged 2 months agoMITView on GitHub

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 and imperative Apex, CustomEvent and Lightning Message Service communication, lifecycle hooks, template directives (lwc:if, for:each, iterator, lwc:ref, lwc:dom="manual", slots), SLDS styling hooks, shadow vs light DOM, NavigationMixin, toasts, labels and i18n, static resources and third-party JS, Lightning Web Security constraints, and performance/accessibility rules. Use this skill whenever work touches force-app/**/lwc/**, a *.js-meta.xml file, a wire adapter such as getRecord or graphql, an @AuraEnabled(cacheable=true) method consumed from JavaScript, refreshApex, a LightningMessageChannel, or when a component must be exposed to a Lightning App Page, Record Page, Flow screen, quick action, or Experience Cloud site.

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never mutate an @api property from inside the component.

    line 132
  2. Very specific:

    Never hardcode user-facing strings: import custom labels via @salesforce/label/c.Name and locale data via @salesforce/i18n/*.

    line 347

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Lightning Web Components Development

When to use

SituationUse this skill
Creating or editing anything under force-app/main/default/lwc/Yes
Choosing between @wire, imperative Apex, and Lightning Data ServiceYes
Component must appear in Lightning App Builder, Flow Builder, or Experience BuilderYes
Writing the Apex controller behind the componentSkill sf-apex-development (this skill covers only the JS side of the contract)
Writing Jest tests for the componentSkill sf-lwc-jest-testing
Previewing the component without deployingSkill sf-local-development
ESLint/PMD/Code Analyzer findings on LWC JSSkill sf-code-analyzer-quality
Generating a component, spec or migration with Salesforce DX MCP tools or Agentforce Vibes LWC skillsYes - 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

NeedMechanismCacheableRefresh 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 picklistsgetObjectInfo, getPicklistValuesYes (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 fieldscreateRecord / updateRecord / deleteRecord (lightning/uiRecordApi)n/aLDS updates wires automatically
Write with server-side business logicimperative Apex (@AuraEnabled, never cacheable=true)Noawait notifyRecordUpdateAvailable([{recordId}])
Form UI with zero JSlightning-record-form / lightning-record-edit-formn/aonsuccess event
Fire-and-forget action on user clickimperative Apex in an async handlerNon/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

HookFiresSafe to doNever do
constructor()Once, before insertionCall super() first, set non-DOM defaultsTouch this.template, read @api values
connectedCallback()On DOM insertion, parent to childSubscribe (LMS, message bus), read @api props, kick off imperative callsAssume children rendered
render()Before each renderReturn an imported template for multi-template componentsSide effects
renderedCallback()After every render, child to parentOne-time third-party init guarded by a boolean, imperative DOM readsMutate @api/wire-config props (infinite loop)
disconnectedCallback()On DOM removalunsubscribe, clearInterval, abort fetchesAssume it runs on tab close
errorCallback(error, stack)Child throws in lifecycle/renderRender a fallback, logSwallow 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-patternWhy it failsFix
this.filters.status = 'x' on an untracked object and expecting a rerenderField was not reassigned; no mutation detectedthis.filters = { ...this.filters, status: 'x' }
@track on every fieldDeep proxying cost with no benefit; fields are already reactiveRemove; use @track only for in-place mutation
Setting a wired config property inside renderedCallback()Re-provision triggers rerender triggers hook: infinite loopSet it in connectedCallback() or a handler
@AuraEnabled(cacheable=true) on a method that performs DMLRuntime error; cacheable methods cannot writeSplit into a cacheable read and a non-cacheable write
Calling an imperative Apex writer and expecting @wire(getRecord) to updateLDS does not know the record changedawait notifyRecordUpdateAvailable([{recordId}])
new CustomEvent('select', { bubbles: true, composed: true }) for a parent handlerLeaks the event to the whole page; breaks encapsulationDefault (bubbles: false) and handle on the child tag
Custom pubsub.js module for sibling communicationNot supported across DOM trees or Aura/Visualforce; leaks subscriptionsLightning Message Service with MessageContext and unsubscribe
element.shadowRoot.querySelector from a parent componentBreaks encapsulation; blocked/locked at runtime@api method or event
key={index} in for:eachDiffing reuses the wrong nodes on reorderkey={row.id}
console.log left in shipped componentsLeaks data to the browser console in productionRemove; vf-check lint flags no-console
Building a form with 12 lightning-input fields plus manual DMLReimplements field-level security, layouts, and validationlightning-record-edit-form or lightning-record-form
CDN <script src> or inline <script>Blocked by CSP and Lightning Web SecurityStatic 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

  • references/component-reference.md - decorators, lifecycle hooks, every template directive, js-meta.xml targets, @salesforce/* module table.
  • references/data-access-patterns.md - wire vs imperative vs LDS matrix, GraphQL wire adapter, reduceErrors, record-form components.
  • references/events-and-messaging.md - CustomEvent propagation matrix, LMS setup, parent/child patterns, Aura/Visualforce interop.
  • references/mcp-and-skills.md - lwc-experts, aura-experts and experts-validation toolsets, per-tool GA status, the LWC skills that superseded them, and the checklist for accepting generated code.
  • references/lwc-recipes-catalog.md - curated runnable patterns mapped to trailheadapps/lwc-recipes.
  • Official: Lightning Web Components Developer Guide, Lifecycle Hooks, renderedCallback(), XML Configuration File Elements, notifyRecordUpdateAvailable, Lightning Message Service, Lightning Web Security.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.