Lightning Web Components (LWC) for Apex Developers
Learn Lightning Web Components as an Apex developer: the three files, reactive state, events, and calling Apex with @wire, plus free LWC challenges to practise.
TL;DR
A Lightning Web Component is three files in a folder: a JavaScript file that
holds the logic, an HTML file that holds the markup, and an optional CSS file
for styling. You extend LightningElement, keep your data in plain JavaScript
properties, and let the framework re-render the template whenever a property
changes. To reach Apex you import a method with @wire, much the way you
would import a static method in Apex. If you already write Apex, most of the
mental model transfers. You just have to learn one new idea: the template is
bound to your state, not to your code.
Why An Apex Developer Should Care About LWC
Apex runs on the server. LWC runs in the browser. That single difference drives everything else in this post.
Apex works with data that already lives in a record: you query it, loop over it, and call a method that returns a result. LWC works with data that the person using the screen is looking at, so it needs to hold state, react when that state changes, and talk to the server when it needs fresh data.
Here is a complete component. It shows a greeting, and the greeting comes from a property rather than from a hard-coded string.
import { LightningElement, api } from 'lwc';
export default class HelloGreeting extends LightningElement {
@api greeting = 'Hello';
get message() {
return this.greeting + ', World!';
}
}<template>
<p>{message}</p>
</template>Three things are happening here. The class extends LightningElement, which
is what makes it a component at all. The greeting property is marked with
@api, which means a parent component can set it when it uses this one. The
message getter is a computed property: the template asks for it, the getter
runs, and the result appears.
The style file is optional. When you need one, it is a plain CSS file with the same name as the other two:
p {
font-weight: bold;
}The Three Files And Why There Are Three
A component folder holds up to three files with a matching name. The platform does not require a particular folder layout, but the convention is that the JavaScript file, the template file, and the style file share one base name.
The split matters because each file has one job.
The JavaScript file holds state and behaviour. It holds the properties your component tracks, the methods your template calls, and the imports that bring in Salesforce capabilities.
The template file holds markup. It is HTML with curly-brace bindings, and it cannot contain arbitrary JavaScript. If you find yourself writing a loop with logic inside it in the template, that logic belongs in the JavaScript file as a getter.
The CSS file holds styles. It is scoped to the component, so a rule like
p { font-weight: bold; } applies to paragraphs inside this component and
does not leak out into the page around it.
If you have worked with Visualforce, this is the same split. Visualforce kept markup, controller, and styles together in one file. LWC separates them, and that separation is what makes a component reusable.
State That Updates The Screen
This is the one idea that has no direct Apex equivalent, so it is worth spending a minute on it.
A plain JavaScript object is a poor fit for a screen that changes. If you mutate an array and the page does not update, you get a bug where the data is right but the display is stale. Lightning Web Components solve this by tracking the properties that the template reads.
Here is the thing that surprises people coming from older tutorials. Every field in a component is reactive. Assign a new value to it and the template re-renders. You do not need a decorator for that.
import { LightningElement } from 'lwc';
export default class TaskTracker extends LightningElement {
tasks = [];
addTask() {
this.tasks = [...this.tasks, 'New task'];
}
}That line assigns a brand new array, so the framework sees a new value on
tasks and re-renders. Building a new array with the spread operator and
assigning it is the pattern to reach for.
There is one case that still needs @track: a field that holds an object or
an array where you change what is inside it without replacing the field
itself. @track tells the framework to watch the contents as well as the
field.
<template>
<button onclick={addTask}>Add</button>
<template for:each={tasks} for:item="task">
<p key={task}>{task}</p>
</template>
</template>The @track tells the framework to watch the contents of that property, not just
the field. Use it when you change a nested value in place, like
this.task.done = true. The rest is ordinary JavaScript. Note the for:each
and for:item attributes: that is how the template renders a list, and key
is how the framework knows which row is which. It is the same idea as the
key you already use in React, if you have been there.
For simple values you do not need any decorator. A plain field is already reactive when you assign to it.
import { LightningElement } from 'lwc';
export default class ClickCounter extends LightningElement {
count = 0;
get countLabel() {
return 'Count: ' + this.count;
}
handleClick() {
this.count += 1;
}
}<template>
<p class="count">{countLabel}</p>
<button class="increment" onclick={handleClick}>Increment</button>
</template>count is a plain field, and it still updates the screen. The reason is that
the framework watches the fields a template binds to, and it re-renders when
one of them is assigned. A getter like countLabel is recomputed whenever
count changes, so the label stays in sync.
Events: The Template Talks Back To The Component
In Apex, a trigger runs when a record changes. In a Lightning Web Component, a template event runs when the person using the screen does something.
Look at the click counter again. The template holds onclick={handleClick}.
The browser fires the click, the template routes it to the handleClick
method on the JavaScript side, and the method updates count.
The naming convention matters. The template attribute is on plus the event
name, and the value is the method name in braces. So onchange={handleChange}
calls handleChange.
The method receives the raw browser event, and the interesting part is
event.target:
import { LightningElement } from 'lwc';
export default class FullNameGetter extends LightningElement {
firstName = '';
lastName = '';
get fullName() {
return this.firstName + ' ' + this.lastName;
}
handleChange(event) {
this.firstName = event.target.value;
}
}<template>
<input value={firstName} onchange={handleChange} />
<p>{fullName}</p>
</template>event.target is the input element itself, and event.target.value is what
the person typed. This is standard DOM, not a Lightning idea.
A second kind of event travels the other way: from a child component up to
its parent. You handle that with CustomEvent and the dispatchEvent call.
When you build a component that a parent composes, that is how the child says
"this happened" without knowing anything about the parent.
Calling Apex From A Component
This is where your Apex experience pays off. You import an Apex method the
same way you would import any other module, and you wire it to a property
with @wire.
import { LightningElement, wire } from 'lwc';
import { getAccounts } from '@salesforce/apex/AccountController.getAccounts';
export default class AccountList extends LightningElement {
@wire(getAccounts)
accounts;
}<template>
<template for:each={accounts.data} for:item="account">
<p key={account.Id}>{account.Name}</p>
</template>
</template>Read the import line carefully. The path is
@salesforce/apex/ClassName.methodName. The part after apex is your Apex
class and a method on it, and the compiler builds the call for you. You do not
write a callout, and you do not handle a response envelope.
One detail in the template catches people out. When you wire a property,
the value the wire emits is an object with two keys: data and error. So
the list lives at accounts.data, not at accounts. The error key holds
the failure when the call fails. Wiring a function instead gives you the
same two keys, but as the function's argument, which is where refreshApex
comes back in later.
The Apex method needs to be marked for access:
public with sharing class AccountController {
@AuraEnabled(cacheable=true)
public static List<Account> getAccounts() {
return [SELECT Id, Name FROM Account ORDER BY Name LIMIT 50];
}
}@AuraEnabled is the annotation that exposes a static Apex method to the
client. The cacheable=true argument tells the platform the call only reads
data. That argument is not optional for a wired method: @wire can only call
a cacheable method, because the framework is allowed to serve the result
from the client-side cache. A method that writes records cannot be cached, so
it does not take that argument, and you call it imperatively instead.
Two rules keep this from biting you later. First, an @AuraEnabled method
must be static, because the framework calls it without an instance of your
class. Second, a with sharing class is the safe default, because it applies
the running user's record access instead of running with full system access.
If you need to pass an argument, @wire takes a configuration object, and any
value prefixed with $ points at a reactive property on the component. Change
that property and the wire re-runs with the new value. And if you need to run
something that writes, you call the method imperatively instead, from a method
the template invokes.
Imperative Apex And The Refresh
Sometimes a wire is not what you want. A wire runs when the data it depends on changes. An imperative call runs when you ask it to, which is what you want after the person clicks Save.
import { LightningElement } from 'lwc';
import { saveAccount } from '@salesforce/apex/AccountController.saveAccount';
export default class AccountForm extends LightningElement {
accountName = '';
handleNameChange(event) {
this.accountName = event.target.value;
}
async handleSave() {
await saveAccount({ name: this.accountName });
}
}The await matters. Without it, you would not know whether the save worked
before you told the person it did. Wrap it in a try and catch when the
server can reject the call, and the pattern matches what you already write in
Apex.
One thing that trips up Apex developers: after an imperative call changes
data, a wire does not automatically refresh. The wire refreshes when the
parameters it depends on change, and a server-side write does not change them.
The fix is to ask the wire to re-run by calling refreshApex and handing it
the value the wire gave you.
import { LightningElement, wire } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import { getAccounts } from '@salesforce/apex/AccountController.getAccounts';
import { saveAccount } from '@salesforce/apex/AccountController.saveAccount';
export default class AccountList extends LightningElement {
@wire(getAccounts)
accounts;
async handleSave() {
await saveAccount({ name: 'Acme' });
await refreshApex(this.accounts);
}
}refreshApex is the piece most tutorials skip. It comes from
@salesforce/apex, and it takes the wired property itself. Note that the
property is this.accounts, not this.accounts.data — the function wants
the whole value the wire emitted, and handing it a slice of that value
silently does nothing.
You can also wire a function instead of a property. In that case you save the
value the function receives and pass that to refreshApex:
import { LightningElement, wire } from 'lwc';
import { refreshApex } from '@salesforce/apex';
import { getAccounts } from '@salesforce/apex/AccountController.getAccounts';
export default class AccountList extends LightningElement {
wiredAccounts;
@wire(getAccounts)
accountResult(result) {
this.wiredAccounts = result;
}
async handleSave() {
await refreshApex(this.wiredAccounts);
}
}There is no refresh() method sitting on a wired property. If a tutorial
tells you to call this.something.refresh(), it is describing an older
pattern that does not exist in the platform today.
Finding Elements And Keeping The DOM In Your Hands
Apex developers sometimes reach for a query when they need a record id. The
Lightning equivalent is this.template.querySelector, and it works the way
you expect.
import { LightningElement } from 'lwc';
export default class Panel extends LightningElement {
handleToggle() {
const panel = this.template.querySelector('.panel');
panel.classList.toggle('open');
}
}<template>
<button onclick={handleToggle}>Toggle</button>
<div class="panel">Hidden content</div>
</template>.panel {
display: none;
}
.panel.open {
display: block;
}this.template.querySelector searches your own template.
this.template.querySelectorAll returns every match as a list. Both return
null when nothing matches, so guard the result before you use it.
The word template in that expression means the element you are looking for
has to be in this component's own markup. It cannot find something that lives
inside a child component, because that markup belongs to the child. This is
the same encapsulation rule as Apex private members, and it keeps a parent
from reaching into a child and changing how it works.
Composing Components
A component that does one thing well is worth more than a big one. When a component grows, split it and let a parent lay the pieces out.
<template>
<c-child-badge label={label}></c-child-badge>
</template>The child is another component folder, imported into the parent's JavaScript
file and used in the parent's template as a custom element. The parent passes
data down with attributes, and the child exposes the attributes it accepts
with @api.
import { LightningElement } from 'lwc';
import ChildBadge from 'c/childBadge';
export default class Parent extends LightningElement {
label = 'Parent';
}This is where the @api decorator from the first example comes back. A
property marked @api is a public door: the parent can set it. Everything
else on the component stays private to the component itself.
Common Mistakes
Forgetting that the template cannot hold logic. A template is markup with bindings. Move anything conditional or computational into a getter on the JavaScript side.
Using this inside the template. In a template you reference a property
by its name, with no this. Write {count}, not {this.count}.
Forgetting static on an @AuraEnabled method. The framework calls the
method without an instance of your class, so it has to be static.
Leaving with sharing off. A class without a sharing declaration runs
with full system access. Put with sharing on it unless you have a specific
reason not to.
Expecting a wire to refresh after a write. A wire reacts to its
parameters, not to server-side changes. Call refreshApex from
@salesforce/apex after an imperative write, and hand it the value the wire
emitted.
Iterating the wired property instead of its data. A wired property
holds an object with data and error, so the list is at accounts.data.
Expecting the template to find a child's markup. querySelector only
searches your own template.
Where To Practice Next
Reading about a component is not the same as writing one. Lightning Challenge runs a real Lightning Web Component compiler in the browser, so you can write a component, see it compile, and check the behaviour against tests without installing anything.
Start with the LWC hello greeting to get a component rendering with a getter. Then try the click counter for state and event handlers. Contact search pushes into list rendering and data handling.
When you want the Apex side of the same picture, the Integration Builder path covers callouts and async Apex, and compress string is a good place to practise the Apex logic that an LWC will eventually call.
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →