You can learn LWC syntax in an afternoon, but what actually takes longer is knowing which of four ways to pass data between components, why renderedCallback keeps firing, and when @wire will quietly serve you stale data.
This guide covers the framework properly, with code you can paste into a scratch org and the current details most LWC content still hasn’t caught up with.
Quick Answer: LWC is Salesforce’s modern UI framework, built on web standards rather than a proprietary abstraction. Three files per component. Use @wire for displaying data, imperative Apex for doing things. Pass data down with @api, up with CustomEvent and sideways with Lightning Message Service – not the old pub/sub module. Guard renderedCallback and know that Lightning Web Security has replaced Locker Service.
What Are Lightning Web Components?
Lightning Web Components are Salesforce’s UI framework, built on native web standards, Web Components, custom elements, shadow DOM, and modern JavaScript. Where Aura wrapped everything in a proprietary layer, LWC leans on what browsers already do, which is why it’s faster and why what you learn transfers to any modern JavaScript work.
- Why LWC Replaced Aura: Browsers caught up; Aura existed because the web platform lacked components, modules, and templating in 2014. Once browsers gained those natively, a proprietary framework became overhead rather than an advantage.
- Aura’s Status: Still supported, not deprecated but no longer receiving meaningful investment. Build new work in LWC. See LWC vs Aura vs Visualforce.
Component Anatomy and Lifecycle

A Minimal Component:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<!-- accountList.html --> <template> <lightning-card title="Accounts"> <template for:each={accounts} for:item="acc"> <p key={acc.Id}>{acc.Name}</p> </template> </lightning-card> </template> // accountList.js import { LightningElement, api } from 'lwc'; export default class AccountList extends LightningElement { @api recordId; accounts = []; connectedCallback() { // component is in the DOM - safe to fetch } } <!-- accountList.js-meta.xml --> <?xml version="1.0" encoding="UTF-8"?> <LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata"> <apiVersion>68.0</apiVersion> <isExposed>true</isExposed> <targets> <target>lightning__RecordPage</target> <target>lightning__AppPage</target> </targets> </LightningComponentBundle> |
The meta XML is not optional boilerplate; if your component doesn’t appear in Lightning App Builder, isExposed or targets is almost always why. Set apiVersion to the API version your org runs. Winter ’27 is API 68.0, and a version newer than your org supports won’t deploy.
The hook that causes the most trouble
renderedCallback() fires after every render, not just the first. Change a reactive property inside it, and you’ve built an infinite loop:
|
1 2 3 4 5 |
renderedCallback() { if (this.hasRendered) return; // guard it this.hasRendered = true; // one-time DOM work here } |
Decorators and Reactivity
Three decorators and the rules changed at Spring ’20 in a way plenty of tutorials still get wrong.
@api makes a property public so a parent can set it; public properties are reactive by default.
@track is mostly unnecessary now. Since Spring ’20, all fields are reactive automatically. You only need @track when you mutate an object’s internal property or an array’s contents rather than reassigning the whole thing:
|
1 2 3 4 5 6 |
// Reactive without @track - reassignment this.account = { ...this.account, Name: 'New' }; // Needs @track - internal mutation @track account = { Name: '' }; this.account.Name = 'New'; |
- Prefer Reassignment Over Mutation: It’s cleaner, it works without @track, and it avoids a class of bug that’s genuinely hard to spot.
@wire connects a property or function to a Salesforce data service.
Have an LWC Project in Mind?
Let us know what you’re building, improving, or troubleshooting, and our Salesforce experts can help you determine the right technical approach.
Getting Data: @wire vs Imperative Apex

@wire – reactive and cached:
|
1 2 3 4 5 6 7 8 9 |
import { LightningElement, api, wire } from 'lwc'; import getAccounts from '@salesforce/apex/AccountController.getAccounts'; export default class AccountList extends LightningElement { @api industry; @wire(getAccounts, { industry: '$industry' }) accounts; // { data, error } } |
The $ prefix makes the parameter reactive – change the industry and the wire re-fires automatically.
Imperative – you control it:
|
1 2 3 4 5 6 7 8 |
import getAccounts from '@salesforce/apex/AccountController.getAccounts'; async handleClick() { try { this.accounts = await getAccounts({ industry: this.industry }); } catch (error) { this.error = error.body.message; } } |
The Apex Side Matters: A method is only wireable if it’s marked cacheable=true:
|
1 2 3 4 5 |
@AuraEnabled(cacheable=true) public static List<Account> getAccounts(String industry) { return [SELECT Id, Name FROM Account WHERE Industry = :industry WITH USER_MODE]; } |
And cacheable=true means it cannot perform DML, so any save, update, or delete must be a separate, non-cacheable method called imperatively.
Refreshing Stale Data
Wired Apex results come from a client-side cache, and Salesforce doesn’t guarantee how long that cache lasts. When you know the data has changed, refresh it yourself: call refreshApex() with the value your @wire provided, and after an imperative Apex save, call notifyRecordUpdateAvailable() so Lightning Data Service reloads those records everywhere they’re shown.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { LightningElement, api, wire } from 'lwc'; import { refreshApex } from '@salesforce/apex'; import { notifyRecordUpdateAvailable } from 'lightning/uiRecordApi'; import getAccounts from '@salesforce/apex/AccountController.getAccounts'; // updateAccount: your non-cacheable Apex method that does the DML import updateAccount from '@salesforce/apex/AccountController.updateAccount'; export default class AccountList extends LightningElement { @api recordId; @api industry; @wire(getAccounts, { industry: '' }) accounts; async handleSave(event) { await updateAccount({ accountId: this.recordId, name: event.detail.name }); await notifyRecordUpdateAvailable([{ recordId: this.recordId }]); // refresh LDS cache await refreshApex(this.accounts); // refresh the wired list } } |
- Don’t forget Lightning Data Service: For single-record work, getRecord and updateRecord from lightning/uiRecordApi need no Apex at all, respect FLS automatically, and share a cache across components:
|
1 2 3 4 |
import { getRecord } from 'lightning/uiRecordApi'; @wire(getRecord, { recordId: '$recordId', fields: ['Account.Name'] }) account; |
Related: Apex programming guide · SOQL guide
Component Communication

Table showing which communication method to use for parent-to-child, child-to-parent, sibling, and cross-page component communication
Parent to child – A public property:
|
1 2 3 4 5 |
// child.js @api message; <!-- parent.html --> <c-child message={parentValue}></c-child> |
Child to parent – a CustomEvent:
|
1 2 3 4 5 6 7 |
// child.js this.dispatchEvent(new CustomEvent('selected', { detail: { recordId: this.recordId } })); <!-- parent.html --> <c-child onselected={handleSelected}></c-child> |
Name events the way Salesforce recommends: all lowercase, no spaces, underscores between words (account_selected), and never start the name with “on”. The handler attribute in the parent is “on” plus the event name, so an event called onselect would need ononselect. An event dispatched with uppercase letters, like accountSelected, can’t be handled with an on… attribute in the template, which costs people an hour the first time.
Sibling Or Unrelated – Lightning Message Service:
|
1 2 3 4 5 6 7 8 9 |
import { subscribe, MessageContext } from 'lightning/messageService'; import RECORD_CHANNEL from '@salesforce/messageChannel/RecordChannel__c'; @wire(MessageContext) messageContext; connectedCallback() { this.subscription = subscribe(this.messageContext, RECORD_CHANNEL, (message) => this.handleMessage(message)); } |
LMS is the supported answer, and it works across LWC, Aura, and Visualforce on the same page. The old pubsub module was always a sample pattern rather than a supported API – if you have it in your org, migrate.
The common mistake is over-engineering; if a public property would do, use a public property.
Navigation
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { LightningElement, api } from 'lwc'; import { NavigationMixin } from 'lightning/navigation'; export default class MyComponent extends NavigationMixin(LightningElement) { @api recordId; navigateToRecord() { this[NavigationMixin.Navigate] ( { type: 'standard__recordPage', attributes: { recordId: this.recordId, actionName: 'view' } }); } } |
NavigationMixin works across Lightning Experience, Experience Cloud, and the mobile app – which hardcoded URLs do not.
Testing With Jest
Jest is the standard, and LWC testing is genuinely straightforward once the setup exists. This test is for the @wire version of AccountList from the “Getting Data” section. Its template reads the wired result from accounts.data:
|
1 2 3 4 5 6 7 8 9 |
<template lwc:if={accounts.data}> <template for:each={accounts.data} for:item="acc"> <p key={acc.Id}>{acc.Name}</p> </template> </template> |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import { createElement } from 'lwc'; import AccountList from 'c/accountList'; import getAccounts from '@salesforce/apex/AccountController.getAccounts'; // Mock the Apex wire adapter jest.mock( '@salesforce/apex/AccountController.getAccounts', () => { const { createApexTestWireAdapter } = require('@salesforce/sfdx-lwc-jest'); return { default: createApexTestWireAdapter(jest.fn()) }; }, { virtual: true } ); describe('c-account-list', () => { afterEach(() => { while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } jest.clearAllMocks(); }); it('renders account names from the wire', async () => { const element = createElement('c-account-list', { is: AccountList }); document.body.appendChild(element); getAccounts.emit([{ Id: '001', Name: 'Acme' }]); await Promise.resolve(); const items = element.shadowRoot.querySelectorAll('p'); expect(items[0].textContent).toBe('Acme'); }); }); |
Four things that trip people up:
- Always clean up in afterEach: Leftover DOM between tests causes failures that look random.
- await Promise.resolve() before asserting: LWC renders asynchronously; assert too early, and the DOM isn’t there yet.
- Query through shadowRoot: Components are encapsulated – document.querySelector won’t find inside them.
- Only public properties can be set from a test: element.someField = … works only for @api properties. For wired data, mock the wire and call .emit(), as in the test above.
Jest tests aren’t counted in your 75% Apex coverage and aren’t required to deploy. That’s exactly why most orgs have none and why the ones that do have noticeably fewer front-end regressions.
Related: Salesforce DevOps and CI/CD
Lightning Web Security
Lightning Web Security (LWS) is the current security architecture for Lightning components. It has been on by default in every org created since Winter ’23, and since Summer ’23 it has been generally available in all orgs for both Lightning web components and Aura components. Lightning Locker (formerly Locker Service) is still maintained but no longer enhanced, and older orgs may still be running it.
The practical difference: Locker restricted access to standard JavaScript APIs fairly aggressively. LWS is less restrictive, permits more standard JavaScript, and gives better third-party library support – while still enforcing namespace isolation between components.
- What It Means For You: Libraries that failed under Locker often work under LWS, so if you shelved a third-party library, re-test it. To check or switch your org, go to Setup → Session Settings → “Use Lightning Web Security for Lightning web components and Aura components”. Test in a sandbox first, and keep “Enable Stricter Content Security Policy” on, because LWS relies on it.
Performance Patterns
- Prefer @wire Where It Fits: Platform caching means fewer server round trips.
- Use Lightning Data Service for single-record reads and writes – shared cache, no Apex.
- Never Query Inside A Loop In Your Apex Controller: The LWC will look slow when the problem is server-side. See governor limits.
- Guard renderedCallback.
- Use lightning-datatable: Rather than building your own table. It comes with keyboard navigation and screen-reader support, and with enable-infinite-loading and an onloadmore handler you can load rows in batches instead of all at once.
- Render on demand with lwc: if so hidden components aren’t created until they’re needed. The older if:true and if:false directives are legacy and no longer recommended.
- Debounce user input before firing server calls – a keystroke-per-query search box is the classic mistake.
|
1 2 3 4 5 6 |
<template lwc:if={showDetails}> <c-account-details record-id={recordId}></c-account-details> </template> <template lwc:else> <lightning-button label="Show details" onclick={handleShow}></lightning-button> </template> |
Where LWC Runs
- Lightning Experience: record pages, app pages, home pages, Quick Actions.
- Experience Cloud: with a caveat. LWR sites support LWC natively; older Aura-based sites have restrictions. Check which template your site uses before promising a component will work.
- Mobile: via the Salesforce mobile app or Mobile Publisher. Design for touch and test on a real device; the desktop layout rarely survives contact with a phone.
- Outside Salesforce: LWC Open Source runs anywhere JavaScript does, though the base components and platform services don’t come with it.
Related: Salesforce mobile app development · Experience Cloud
Turn LWC Problems Into Better Experiences
Get expert support for LWC development, performance optimization, component modernization, and Salesforce front-end engineering.
Common LWC Mistakes
- Not guarding renderedCallback – Infinite re-render loops
- Using @track unnecessarily – Reactivity has been automatic since Spring ’20
- Camel-Case Event Names – onmyEvent never binds
- Reaching for LMS when a public property would do
- Forgetting cacheable=true on wired Apex methods
- Attempting DML In A Cacheable Method – it will fail
- Missing isExposed Or Targets in the meta XML
- Querying the DOM outside shadowRoot
- No Jest Tests, because nothing forces you to write them
- Hardcoded URLs instead of NavigationMixin
- Ignoring error On A Wired Property – Silent failures
- Building Custom Tables instead of using lightning-datatable
Need More Salesforce Development Capacity?
Bring experienced Salesforce developers into your team to build, improve, and maintain LWC applications without slowing down your roadmap.
Where to Go Next
For the Apex behind your components, see the Apex programming guide and the SOQL guide.
For choosing between frameworks, LWC vs Aura vs Visualforce and Lightning Experience.
For deploying components safely, Salesforce DevOps and sandboxes.
For components on phones, Salesforce mobile app development.
The complete Salesforce development guide is the hub.
When the Front End Is the Symptom
Slow components are usually slow queries; fragile components are usually untested ones. Components nobody will touch are usually components nobody documented.
We’re a certified Salesforce Consulting Partner, and front-end remediation is routine work for our team. Our Salesforce Development Services can help with profiling what’s actually slow, adding Jest coverage, and restructuring components so the next developer can change them safely.



Your email address will not be published. Required fields are marked *