TL;DR
- LWC templates accept only property references such as {fullName} or {contact.Name}. Operators, ternaries, negation, method calls and bracket access don't compile.
- Put the logic in a JavaScript getter and bind the getter by name. It works for labels, CSS classes, lwc:if conditions and formatted values.
- For lists, add derived fields to each item in JavaScript before for:each renders them, and reassign arrays and objects instead of mutating them.
Lightning Web Components templates don't allow expressions. You can bind a property ({fullName}) or a property path ({contact.Name}), but not {a + b}, a ternary, !isLoading, {items.length > 0} or a method call. The fix is the getter pattern: write a get accessor in the component's JavaScript that computes the value, then bind the getter by name, as in {isEmpty} or {badgeClass}. Getters re-run on every render, so they stay in sync with your data automatically.
Below I'll explain why LWC works this way, show the getter pattern for labels, CSS classes, conditional rendering, formatting and lists, and cover the reactivity gotchas and Jest tests that go with it. Every template in this post contains zero expressions.
What LWC templates allow, and what they don't
Inside the curly braces of an LWC template, you can write:
- A property or getter name:
{greeting},{isEmpty} - A dot path into an object:
{contact.Name},{row.rowClass} - A handler reference:
onclick={handleClick}
That's the whole list. None of these compile:
| You might write | Why it fails | Getter instead |
|---|---|---|
{firstName + ' ' + lastName} | Operator | {fullName} |
{isActive ? 'On' : 'Off'} | Ternary | {statusLabel} |
lwc:if={!isLoading} | Negation | lwc:if={isReady} |
{items.length > 0} | Comparison | {hasItems} |
{formatDate(closeDate)} | Method call | {formattedCloseDate} |
{items[0]} | Bracket access | {firstItem} |
onclick={select(row.id)} | Call with arguments | data-id and onclick={handleSelect} |
If you're coming from Aura or Visualforce, this is the big adjustment. Aura's {!v.count > 0} and Visualforce's {!IF(...)} let you put logic in markup. LWC doesn't.
{items.length} is technically allowed, because it's a dot path. I still write a hasItems getter, because lwc:if wants a boolean and the getter's name says what I actually mean.
Why LWC templates don't allow expressions
I see three reasons behind this design, and after years of writing components I agree with all of them.
The template stays declarative. A template describes what is on screen. Once markup can contain arbitrary JavaScript, business rules leak into it a bit at a time. Six months later, nobody can find the rule that hides the Approve button, because it's buried in a ternary on line 140 of an HTML file.
The logic is testable. A getter is a plain JavaScript function on the class. You can test it in Jest, reuse it, debug it with a breakpoint and read it in a code review. An inline expression in a template is none of those things.
The compiler and the engine know exactly what the template reads. LWC compiles templates into JavaScript render functions. When every binding is a simple property reference, the compiler can check it at build time and the engine can see which properties a render depends on. It also means markup can't run arbitrary code, which keeps the template layer small and predictable.
The cost is a few more lines of JavaScript. The benefit is that every rule in your component lives in one place, where you can test it.
The getter pattern
A getter is a property computed from other properties. LWC re-evaluates it when the component re-renders, and the component re-renders when a field the template (or one of its getters) reads gets a new value.
import { LightningElement, api } from 'lwc';
export default class ContactGreeting extends LightningElement {
@api firstName;
@api lastName;
get fullName() {
return [this.firstName, this.lastName].filter(Boolean).join(' ');
}
get greeting() {
return this.fullName ? `Hello, ${this.fullName}` : 'Hello there';
}
}
<template>
<p class="slds-text-heading_small">{greeting}</p>
</template>
The template has one binding, {greeting}, and all the logic (including the "no name yet" case) lives in JavaScript. Here are the patterns I use most.
Computed labels
Labels that depend on counts or state are the most common reason to want an expression:
get contactCountLabel() {
const count = this.rows.length;
return `${count} ${count === 1 ? 'contact' : 'contacts'}`;
}
<lightning-card title={contactCountLabel} icon-name="standard:contact"></lightning-card>
CSS class lists
Build the class string in a getter. I start from the base classes in an array and push modifiers, which stays readable as the rules grow:
get badgeClass() {
const classes = ['slds-badge'];
if (this.isWon) {
classes.push('slds-theme_success');
} else if (this.isClosed) {
classes.push('slds-theme_error');
}
return classes.join(' ');
}
<span class={badgeClass}>{stage}</span>
Conditional rendering with lwc:if bound to a getter
lwc:if, lwc:elseif and lwc:else take a single property reference, so every condition is a getter. That includes "not" conditions, because there's no ! in a template.
get hasError() {
return Boolean(this.error);
}
get hasContacts() {
return this.rows.length > 0;
}
<template lwc:if={isLoading}>
<lightning-spinner alternative-text="Loading contacts" size="small"></lightning-spinner>
</template>
<template lwc:elseif={hasError}>
<p class="slds-text-color_error">{errorMessage}</p>
</template>
<template lwc:elseif={hasContacts}>
<p>Contacts go here.</p>
</template>
<template lwc:else>
<p>No contacts on this account yet.</p>
</template>
Use lwc:if and its siblings rather than the legacy if: directives, which are deprecated.
Formatting values
For display-only formatting, base components such as lightning-formatted-number and lightning-formatted-date-time are often the simplest answer. When you need the formatted string itself, for a title attribute, a label or a concatenated message, use a getter with Intl and the user's Salesforce locale:
import LOCALE from '@salesforce/i18n/locale';
import CURRENCY from '@salesforce/i18n/currency';
const currencyFormatter = new Intl.NumberFormat(LOCALE, { style: 'currency', currency: CURRENCY });
// inside the component class
get formattedAmount() {
if (this.amount === undefined || this.amount === null) {
return 'No amount';
}
return currencyFormatter.format(this.amount);
}
Creating the formatter once at module level, rather than inside the getter, matters because the getter runs on every render.
Event handlers that need a value
You can't write onclick={select(row.id)}. Put the value in a data- attribute and read it from the event:
<lightning-button label="Select" data-id={row.id} onclick={handleSelect}></lightning-button>
handleSelect(event) {
const contactId = event.currentTarget.dataset.id;
this.dispatchEvent(new CustomEvent('contactselect', { detail: { contactId } }));
}
Use currentTarget, not target. target can be a child element inside the button, which won't have the data attribute.
Iterating over pre-computed arrays
Lists are where people get stuck. Inside for:each, each item is a plain object, and you can't call a getter per item. So you can't write a row-level ternary for the CSS class, and you can't call a function per row.
The answer is to add the derived fields in JavaScript before the template sees the list. Map each record to a view model that already has everything the template needs.
Here's a complete component that lists an Account's Contacts and flags stale ones:
public with sharing class AccountContactsController {
@AuraEnabled(cacheable=true)
public static List<Contact> getContacts(Id accountId) {
return [
SELECT Id, Name, Title, Email, LastActivityDate
FROM Contact
WHERE AccountId = :accountId
WITH USER_MODE
ORDER BY Name
LIMIT 200
];
}
}
import { LightningElement, api, wire } from 'lwc';
import getContacts from '@salesforce/apex/AccountContactsController.getContacts';
import NAME_FIELD from '@salesforce/schema/Contact.Name';
import TITLE_FIELD from '@salesforce/schema/Contact.Title';
import EMAIL_FIELD from '@salesforce/schema/Contact.Email';
import LAST_ACTIVITY_FIELD from '@salesforce/schema/Contact.LastActivityDate';
const STALE_AFTER_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;
export default class AccountContacts extends LightningElement {
@api recordId;
rows = [];
error;
isLoading = true;
@wire(getContacts, { accountId: '$recordId' })
wiredContacts({ data, error }) {
if (data) {
this.rows = data.map((contact) => this.toRow(contact));
this.error = undefined;
this.isLoading = false;
} else if (error) {
this.rows = [];
this.error = error;
this.isLoading = false;
}
}
toRow(contact) {
const email = contact[EMAIL_FIELD.fieldApiName];
const lastActivity = contact[LAST_ACTIVITY_FIELD.fieldApiName];
const isStale = !lastActivity || Date.now() - new Date(lastActivity).getTime() > STALE_AFTER_DAYS * DAY_MS;
return {
id: contact.Id,
name: contact[NAME_FIELD.fieldApiName],
title: contact[TITLE_FIELD.fieldApiName] || 'No title',
email,
hasEmail: Boolean(email),
mailtoUrl: email ? `mailto:${email}` : undefined,
rowClass: isStale ? 'slds-item contact-row contact-row_stale' : 'slds-item contact-row',
activityLabel: isStale ? `No activity in ${STALE_AFTER_DAYS} days` : 'Recently active'
};
}
get hasError() {
return Boolean(this.error);
}
get hasContacts() {
return this.rows.length > 0;
}
get contactCountLabel() {
const count = this.rows.length;
return `${count} ${count === 1 ? 'contact' : 'contacts'}`;
}
get errorMessage() {
return this.error?.body?.message || 'Contacts could not be loaded.';
}
}
<template>
<lightning-card title={contactCountLabel} icon-name="standard:contact">
<div class="slds-p-horizontal_small">
<template lwc:if={isLoading}>
<lightning-spinner alternative-text="Loading contacts" size="small"></lightning-spinner>
</template>
<template lwc:elseif={hasError}>
<p class="slds-text-color_error">{errorMessage}</p>
</template>
<template lwc:elseif={hasContacts}>
<ul class="slds-has-dividers_bottom-space">
<template for:each={rows} for:item="row">
<li key={row.id} class={row.rowClass}>
<p class="slds-text-heading_small">{row.name}</p>
<p class="slds-text-body_small">{row.title} · {row.activityLabel}</p>
<template lwc:if={row.hasEmail}>
<a href={row.mailtoUrl}>{row.email}</a>
</template>
</li>
</template>
</ul>
</template>
<template lwc:else>
<p>No contacts on this account yet.</p>
</template>
</div>
</lightning-card>
</template>
Every binding is either a property, a getter or a dot path into a row. The template has no idea what "stale" means. That rule lives in toRow, where a test can reach it.
Three details worth copying:
- Compute rows once, when the data arrives. I map the list in the wire handler and store it in
rows, not in a getter. A getter would re-map the whole list on every render, which is wasteful for long lists. - Field imports from
@salesforce/schema. Readingcontact[NAME_FIELD.fieldApiName]instead ofcontact.Namelets the platform know the component depends on that field, so it can't be deleted from under you. key={row.id}.for:eachneeds a unique, stable key on the top-level element of each item, so the engine can track rows when the list changes.
Getter, field or @api setter: which one?
There are three places derived values can live, and picking the right one saves you from both stale data and wasted renders.
| Use | When | Example |
|---|---|---|
| Getter | The value is cheap to compute from current state | isClosed, badgeClass, contactCountLabel |
| Field set when data arrives | The computation is expensive or runs over a list | rows mapped in the wire handler |
@api getter and setter pair | You need to normalise or copy a value from the parent once | Defaulting a missing list, copying an array you'll change |
A getter is always correct, because it reads current state every time. Its only cost is that it runs on every render. A field is computed once, but you have to remember to recompute it whenever its inputs change. An @api setter runs once per value the parent passes, which makes it the right place to copy or clean incoming data:
import { LightningElement, api } from 'lwc';
export default class SelectedRecords extends LightningElement {
_recordIds = [];
@api
get recordIds() {
return this._recordIds;
}
set recordIds(value) {
this._recordIds = Array.isArray(value) ? [...value] : [];
}
get hasSelection() {
return this._recordIds.length > 0;
}
get selectionLabel() {
const count = this._recordIds.length;
return `${count} ${count === 1 ? 'record' : 'records'} selected`;
}
}
<template>
<template lwc:if={hasSelection}>
<p class="slds-text-body_small">{selectionLabel}</p>
</template>
</template>
Put @api on the getter only, not on both halves of the pair. Because the setter copies the array, the component can sort or filter its own copy without touching the parent's read-only data. The template still only sees two getters.
My rule of thumb: start with a getter. Move to a field when profiling or a long list says the getter is too slow, and to a setter when the parent's data needs cleaning before you can use it.
Reactivity gotchas
Getters only help if the component re-renders when the underlying data changes. These are the traps I see most often.
Reassign, don't mutate. All fields are reactive, but by default LWC notices a new value being assigned to the field, not changes inside an object or array the field already holds.
// Does NOT re-render: mutates the existing object and array
this.filters.status = 'Closed';
this.rows.push(newRow);
// Re-renders: assigns new values
this.filters = { ...this.filters, status: 'Closed' };
this.rows = [...this.rows, newRow];
@track is the exception, not the rule. Decorating a field with @track makes LWC observe changes to the properties of a plain object or the elements of an array, so the mutation style above would re-render. I still prefer reassignment, because it works everywhere and makes changes explicit. Also note that @track doesn't observe changes inside a Date, Set, Map or class instance. Reassign those too.
Wire and @api data is read-only. Records from @wire are read-only, and so are objects a parent passes into an @api property. Trying to add isStale directly to a wired record throws an error. Copy it instead, as toRow does, with a new object or the spread operator.
Getters must be pure. Never assign a field inside a getter. Assigning a reactive field during render triggers another render, which at best is wasteful and at worst loops. A getter should read state and return a value, nothing else.
Getters on @api properties re-run automatically. If a getter reads an @api property, it updates when the parent passes a new value. If you need to transform an incoming value once rather than on every render, use an @api setter that stores the result in a private field.
Testing getters in Jest
Because the logic lives in getters, you can test it through the rendered DOM with @salesforce/sfdx-lwc-jest. Here's a small component built entirely on getters:
import { LightningElement, api } from 'lwc';
import LOCALE from '@salesforce/i18n/locale';
import CURRENCY from '@salesforce/i18n/currency';
const CLOSED_STAGES = ['Closed Won', 'Closed Lost'];
const currencyFormatter = new Intl.NumberFormat(LOCALE, { style: 'currency', currency: CURRENCY });
export default class OpportunitySummary extends LightningElement {
@api name;
@api amount;
@api stage;
get isClosed() {
return CLOSED_STAGES.includes(this.stage);
}
get isWon() {
return this.stage === 'Closed Won';
}
get headline() {
return this.isClosed ? `${this.name} (closed)` : this.name;
}
get formattedAmount() {
if (this.amount === undefined || this.amount === null) {
return 'No amount';
}
return currencyFormatter.format(this.amount);
}
get badgeClass() {
const classes = ['slds-badge'];
if (this.isWon) {
classes.push('slds-theme_success');
} else if (this.isClosed) {
classes.push('slds-theme_error');
}
return classes.join(' ');
}
}
<template>
<article class="slds-box slds-theme_default">
<h2 class="slds-text-heading_small">{headline}</h2>
<p class="amount">{formattedAmount}</p>
<span class={badgeClass}>{stage}</span>
<template lwc:if={isClosed}>
<p class="closed-note">This opportunity is closed.</p>
</template>
</article>
</template>
And the tests:
import { createElement } from 'lwc';
import OpportunitySummary from 'c/opportunitySummary';
function render(props) {
const element = createElement('c-opportunity-summary', { is: OpportunitySummary });
Object.assign(element, props);
document.body.appendChild(element);
return element;
}
describe('c-opportunity-summary', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
});
it('formats the amount', () => {
const element = render({ name: 'Acme renewal', amount: 125000, stage: 'Prospecting' });
const amount = element.shadowRoot.querySelector('.amount');
expect(amount.textContent).toContain('125,000');
});
it('shows a fallback when there is no amount', () => {
const element = render({ name: 'Acme renewal', stage: 'Prospecting' });
expect(element.shadowRoot.querySelector('.amount').textContent).toBe('No amount');
});
it('marks Closed Won as closed with a success badge', () => {
const element = render({ name: 'Acme renewal', amount: 125000, stage: 'Closed Won' });
const badge = element.shadowRoot.querySelector('.slds-badge');
expect(element.shadowRoot.querySelector('.closed-note')).not.toBeNull();
expect(badge.classList.contains('slds-theme_success')).toBe(true);
expect(element.shadowRoot.querySelector('h2').textContent).toBe('Acme renewal (closed)');
});
it('re-renders when a public property changes', async () => {
const element = render({ name: 'Acme renewal', amount: 125000, stage: 'Prospecting' });
expect(element.shadowRoot.querySelector('.closed-note')).toBeNull();
element.stage = 'Closed Lost';
await Promise.resolve();
const badge = element.shadowRoot.querySelector('.slds-badge');
expect(element.shadowRoot.querySelector('.closed-note')).not.toBeNull();
expect(badge.classList.contains('slds-theme_error')).toBe(true);
});
});
The pattern to remember: set properties, append the element, assert on the DOM. After changing a property on an element that's already rendered, await Promise.resolve() lets the re-render finish before you assert. Testing through the DOM instead of calling getters directly means the test also proves the template is bound to the right getter.
For wired components like accountContacts, the same approach works. Emit mock data through the wire adapter in the test, then assert that stale rows get the contact-row_stale class.
The rule in one line
If you're about to type anything other than a property name or a dot path between curly braces in an LWC template, stop and write a getter. Your template stays readable, your logic becomes testable, and your code reviewer stops leaving comments.
Learn LWC the hands-on way
The LWC learning guide maps the whole topic, and the LWC interview questions cover getters, reactivity and @track, which come up in almost every interview. If your JavaScript is shaky, getters, spread syntax and array map will feel like magic, so start with why to learn JavaScript before LWC.
My free Javascript for Lightning Web Components [FREE] course covers the modern JavaScript that LWC relies on. Lightning Web Components Development then builds real components live in an org, covering events, Lightning Data Service, Apex, navigation, Lightning Message Service and testing, with no slides and no pasted code.
Javascript for Lightning Web Components [FREE]
22 episodes · 2.6 hours
Lightning Web Components Development
58 episodes · 7.9 hours · 9 modules
Learn it by building it
Every course, every code repository, the discussion group and certificates of completion — one subscription.
Frequently asked questions
Can I use a ternary operator in an LWC template?
No. LWC templates only accept property references, so a ternary, a comparison or a negation fails to compile. Write a getter in the component's JavaScript that returns the value and bind the getter by name.
How do I pass a parameter to an event handler in LWC?
You can't call a handler with arguments from the template. Put the value in a data attribute such as data-id on the element, then read it in the handler from event.currentTarget.dataset.id.
Do getters run on every render in LWC?
Yes. A getter used in the template is evaluated each time the component renders, so keep getters cheap and free of side effects. For heavy work such as mapping a large list, compute the result once when the data arrives and store it in a field.
Do I still need @track in Lightning Web Components?
Rarely. All fields are reactive when you assign a new value. You only need @track to make the component re-render when you mutate a property inside a plain object or an element of an array without reassigning the field.
How do I add a computed field to each item in a for:each loop?
Map the source array to new objects in JavaScript, adding the derived fields such as a CSS class or a label, and iterate over that new array. Data from @wire is read-only, so copy it rather than modifying it.
Keep going
Topic guide
Learn Lightning Web Components (LWC): A Hands-On Course from Basics to Real Projects
Topic guide
Learn JavaScript for LWC: The Modern JS You Need Before Lightning Web Components
Interview questions
LWC Interview Questions and Answers (2026)
Blog
Learn JavaScript Before LWC: The 12 Concepts That Matter
Career roadmap
Salesforce Developer Roadmap 2026
Blog
How to Crack a Salesforce Developer Interview: A 30-Day Plan