Part 79: Data Binding, Templates, and Composition in LWC
Welcome back. In Part 78 we explored decorators in LWC — @api, @track, and @wire — and how they control the reactive behavior of your component properties. Before that, in Parts 72 through 77, we covered LWC fundamentals, HTML, CSS, the DOM, and JavaScript essentials. Now it is time to go deeper into the template layer itself. How does data flow into your HTML? How do you conditionally render markup, iterate over lists, swap entire templates, and compose components together?
This post covers ten areas:
- What are templates — The HTML file in your LWC and what it actually does.
- What is data binding — How JavaScript properties show up in your markup.
- Getters and setters — Computed values and intercepting property changes.
- Dynamic template rendering —
lwc:if,lwc:elseif,lwc:elsefor conditional markup. - Template for:each — Iterating over arrays in your HTML.
- Swapping templates using the render method — Returning different templates from a single component.
- What is LWC composition — Building complex UIs by nesting components.
- The lwc:spread property — Passing multiple attributes to a child in one shot.
- Slots — Letting parent components inject content into children.
- The slot change event — Reacting when slotted content changes.
By the end you will understand the full template system and be ready to build composed, dynamic interfaces. Let’s get into it.
What Are Templates?
Every LWC has an HTML file that serves as its template. When you create a component called myComponent, the file myComponent.html is the template. This file must be wrapped in a <template> tag — that is the root element of every LWC template.
<!-- myComponent.html -->
<template>
<h1>Hello from my component</h1>
</template>
The template is not raw HTML that gets dumped into the page. The LWC framework compiles it into optimized JavaScript that knows how to create and update DOM nodes efficiently. Think of the template as a declarative description of what the DOM should look like given the current state of your component. When your component’s data changes, the framework re-evaluates the template and updates only the parts of the DOM that actually changed.
You cannot put arbitrary JavaScript expressions inside a template. Unlike some frameworks that let you embed full expressions in curly braces, LWC templates only allow simple property references and method calls. This keeps templates clean and pushes logic into the JavaScript file where it belongs.
What Is Data Binding?
Data binding is the connection between your component’s JavaScript properties and the values displayed in the template. In LWC, data binding is one-way by default — data flows from the JavaScript class to the template. You reference a property using curly braces.
<!-- greeting.html -->
<template>
<p>{message}</p>
</template>
// greeting.js
import { LightningElement } from 'lwc';
export default class Greeting extends LightningElement {
message = 'Welcome to the Salesforce blog series';
}
When the component renders, {message} is replaced with the value of the message property. If message changes later (through a user action, a wire result, or any other mechanism), the template updates automatically. That is reactivity in action — the framework tracks which properties the template depends on and re-renders when they change.
You can bind to attributes as well:
<template>
<a href={linkUrl}>{linkLabel}</a>
<div class={dynamicClass}>Styled content</div>
<input value={inputValue}>
</template>
Notice there are no quotes around {linkUrl} when binding to an attribute. This is intentional — the framework handles the attribute assignment. You cannot use curly braces inside a string like href="https://example.com/{path}". If you need to build a dynamic string, compute it in your JavaScript and bind to the computed property.
// myComponent.js
import { LightningElement } from 'lwc';
export default class MyComponent extends LightningElement {
path = 'about';
get linkUrl() {
return `https://example.com/${this.path}`;
}
}
Getters and Setters
Since you cannot put complex expressions in templates, getters become your best friend. A getter is a JavaScript method that looks like a property to the template. We touched on getters briefly when discussing JavaScript fundamentals in Part 76 — now you see why they matter so much in LWC.
Getters
<!-- userCard.html -->
<template>
<p>{fullName}</p>
<p class={statusClass}>{statusLabel}</p>
</template>
// userCard.js
import { LightningElement, api } from 'lwc';
export default class UserCard extends LightningElement {
@api firstName = '';
@api lastName = '';
@api isActive = false;
get fullName() {
return `${this.firstName} ${this.lastName}`.trim();
}
get statusClass() {
return this.isActive ? 'slds-text-color_success' : 'slds-text-color_error';
}
get statusLabel() {
return this.isActive ? 'Active' : 'Inactive';
}
}
The template calls fullName, statusClass, and statusLabel as if they were regular properties. Behind the scenes, the framework invokes the getter function each time it needs the value. Getters are reactive — if any property referenced inside the getter changes (firstName, lastName, isActive), the framework knows to re-evaluate the getter and update the DOM.
Setters
Setters let you intercept when a parent component passes a new value to a public property. This is useful when you need to transform or validate incoming data. Recall from Part 78 that @api marks a property as public.
// formattedNumber.js
import { LightningElement, api } from 'lwc';
export default class FormattedNumber extends LightningElement {
_rawValue = 0;
@api
get value() {
return this._rawValue;
}
set value(val) {
this._rawValue = Number(val) || 0;
}
get displayValue() {
return this._rawValue.toLocaleString();
}
}
When a parent sets the value attribute, the setter runs, coerces the input to a number, and stores it in a private backing field (_rawValue). The getter returns that backing field. The template uses {displayValue} to render the formatted version. This pattern — getter, setter, and private backing field — is the standard way to handle public properties that need transformation.
LWC Dynamic Template Rendering
Not every piece of markup should render all the time. LWC gives you directives to conditionally include or exclude sections of the template.
lwc:if, lwc:elseif, lwc:else
These directives replaced the older if:true and if:false directives. They work like an if/else chain.
<!-- statusBanner.html -->
<template>
<div lwc:if={isLoading}>
<lightning-spinner alternative-text="Loading"></lightning-spinner>
</div>
<div lwc:elseif={hasError}>
<p>Something went wrong: {errorMessage}</p>
</div>
<div lwc:elseif={isEmpty}>
<p>No records found.</p>
</div>
<div lwc:else>
<p>Showing {recordCount} records.</p>
</div>
</template>
// statusBanner.js
import { LightningElement, api } from 'lwc';
export default class StatusBanner extends LightningElement {
@api isLoading = false;
@api hasError = false;
@api isEmpty = false;
@api errorMessage = '';
@api recordCount = 0;
}
A few rules to remember. The lwc:elseif and lwc:else elements must be immediate siblings of the lwc:if element — you cannot put other elements between them. The value passed to lwc:if and lwc:elseif must be a property or getter, not an expression. If you need to evaluate something like records.length > 0, create a getter that returns a boolean.
get hasRecords() {
return this.records && this.records.length > 0;
}
Then use lwc:if={hasRecords} in the template. This keeps your templates readable and your logic testable.
LWC Template for:each
When you have an array of data, you use for:each to iterate over it and render markup for each item.
<!-- contactList.html -->
<template>
<ul>
<template for:each={contacts} for:item="contact">
<li key={contact.id}>
{contact.name} - {contact.email}
</li>
</template>
</ul>
</template>
// contactList.js
import { LightningElement } from 'lwc';
export default class ContactList extends LightningElement {
contacts = [
{ id: '001', name: 'Alice Johnson', email: 'alice@example.com' },
{ id: '002', name: 'Bob Smith', email: 'bob@example.com' },
{ id: '003', name: 'Carol Williams', email: 'carol@example.com' }
];
}
The key attribute is required on the first element inside the loop. It tells the framework how to identify each item so it can efficiently update the DOM when the array changes. Always use a unique, stable identifier like a record ID — never use the index as the key if the list can be reordered or filtered.
You can also use iterator: instead of for:each when you need access to the first and last flags:
<template>
<ul>
<template iterator:it={contacts}>
<li key={it.value.id}>
{it.value.name}
<span lwc:if={it.first}> (First)</span>
<span lwc:if={it.last}> (Last)</span>
</li>
</template>
</ul>
</template>
With iterator, each item is wrapped in an object with value, index, first, and last properties. Use for:each when you just need the items. Use iterator when you need positional awareness.
Swapping Templates Using the Render Method
Sometimes conditional directives are not enough. What if your component has two completely different layouts — say a card view and a table view — and switching between them with lwc:if would make your single template file unreadable? The render() method lets you return different templates entirely.
First, create multiple HTML files in your component folder:
myComponent/
myComponent.js
myComponent.html
cardView.html
tableView.html
Then import and return them from the render() method:
// myComponent.js
import { LightningElement } from 'lwc';
import cardView from './cardView.html';
import tableView from './tableView.html';
export default class MyComponent extends LightningElement {
showCardView = true;
render() {
return this.showCardView ? cardView : tableView;
}
toggleView() {
this.showCardView = !this.showCardView;
}
}
<!-- cardView.html -->
<template>
<div class="card-layout">
<lightning-card title="Card View">
<p>This is the card layout.</p>
<lightning-button label="Switch to Table" onclick={toggleView}></lightning-button>
</lightning-card>
</div>
</template>
<!-- tableView.html -->
<template>
<div class="table-layout">
<h2>Table View</h2>
<table>
<tr><th>Name</th><th>Value</th></tr>
</table>
<lightning-button label="Switch to Card" onclick={toggleView}></lightning-button>
</div>
</template>
When render() returns a different template, the framework tears down the old DOM and builds the new one. This is heavier than toggling visibility with lwc:if, so use it when the layouts are truly different, not for hiding and showing a single section.
One important thing — when the template swaps, the renderedCallback() lifecycle hook fires again. Keep that in mind if you have logic in renderedCallback that should only run once.
What Is LWC Composition?
Composition is the practice of building complex components by combining simpler ones. Instead of creating one massive component that does everything, you create small, focused components and nest them together. This is not unique to LWC — it is a fundamental principle in all modern component frameworks. But in the Salesforce ecosystem, where teams often build monolithic pages, it is worth emphasizing.
A parent component includes a child component by using its HTML tag:
<!-- parentComponent.html -->
<template>
<c-user-card
first-name="Alice"
last-name="Johnson"
is-active>
</c-user-card>
</template>
The c- prefix indicates a custom LWC in the default namespace. Notice that JavaScript camelCase property names become kebab-case in HTML. firstName becomes first-name. isActive becomes is-active. The framework handles the conversion.
Owner vs Container vs Child
LWC defines three roles in composition:
- Owner — The component that creates the child in its template. The owner can set public properties on the child and add event listeners to it.
- Container — A component that contains another component but did not create it (this happens with slots, which we will cover shortly).
- Child — The component being included. It exposes public properties and fires events upward.
Data flows down through properties. Events flow up through custom events. This is the same parent-to-child, child-to-parent communication pattern you see in React, Vue, and every other modern framework.
// childComponent.js
import { LightningElement, api } from 'lwc';
export default class ChildComponent extends LightningElement {
@api label = '';
handleClick() {
this.dispatchEvent(new CustomEvent('selected', {
detail: { label: this.label }
}));
}
}
<!-- parentComponent.html -->
<template>
<c-child-component
label="Option A"
onselected={handleSelection}>
</c-child-component>
</template>
The parent listens for the selected event using the on prefix convention. This is composition in action — small components communicating through a clean, well-defined interface.
The lwc:spread Property
When you need to pass many attributes to a child component, listing them all individually gets tedious. The lwc:spread directive lets you pass an object of key-value pairs as attributes in one shot.
<!-- parentComponent.html -->
<template>
<c-user-card lwc:spread={userProps}></c-user-card>
</template>
// parentComponent.js
import { LightningElement } from 'lwc';
export default class ParentComponent extends LightningElement {
get userProps() {
return {
firstName: 'Alice',
lastName: 'Johnson',
isActive: true,
department: 'Engineering'
};
}
}
This is equivalent to writing out each attribute individually. The lwc:spread directive takes a getter that returns a plain object. Each key in the object maps to a public property on the child component. If you need to combine spread attributes with individual attributes, you can do both:
<c-user-card lwc:spread={userProps} role="admin"></c-user-card>
Individual attributes override values from the spread object if there is a conflict. This directive is particularly useful when you are building wrapper components or dynamically generating child configurations from data.
What Are Slots and How to Use Them
Slots let a parent component pass markup into a child component. Instead of only passing data through properties, the parent can inject actual HTML content into designated areas of the child’s template.
Default Slot
The simplest slot is an unnamed (default) slot:
<!-- card.html -->
<template>
<div class="card">
<div class="card-header">
<h2>{title}</h2>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
<!-- parentComponent.html -->
<template>
<c-card title="User Info">
<p>This content goes into the slot.</p>
<p>So does this.</p>
</c-card>
</template>
Everything between the opening and closing <c-card> tags in the parent gets projected into the <slot> in the child. If the parent does not provide any content, the slot renders nothing (or you can provide fallback content inside the <slot> tag).
<slot>Default content if nothing is provided</slot>
Named Slots
When you need multiple insertion points, use named slots:
<!-- layout.html -->
<template>
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
<!-- parentComponent.html -->
<template>
<c-layout>
<div slot="header">
<h1>Page Title</h1>
</div>
<p>Main content goes into the default slot.</p>
<div slot="footer">
<p>Footer content here.</p>
</div>
</c-layout>
</template>
The slot attribute on the parent’s elements tells the framework which named slot to project into. Content without a slot attribute goes into the default (unnamed) slot. Named slots are powerful for creating layout components — think page templates, card layouts, or modal dialogs where different sections need to be customizable.
The Slot Change Event
When slotted content changes — elements are added, removed, or replaced — the <slot> element fires a slotchange event. This lets the child component react to changes in the content its parent provides.
<!-- dynamicContainer.html -->
<template>
<div class="container">
<slot onslotchange={handleSlotChange}></slot>
<p lwc:if={hasContent}>Slot has {itemCount} items</p>
<p lwc:if={noContent}>No content provided</p>
</div>
</template>
// dynamicContainer.js
import { LightningElement } from 'lwc';
export default class DynamicContainer extends LightningElement {
itemCount = 0;
get hasContent() {
return this.itemCount > 0;
}
get noContent() {
return this.itemCount === 0;
}
handleSlotChange(event) {
const slot = event.target;
const assignedNodes = slot.assignedNodes();
this.itemCount = assignedNodes.filter(
node => node.nodeType === Node.ELEMENT_NODE
).length;
}
}
The slotchange event fires when the distributed nodes of a slot change. Inside the handler, you can call slot.assignedNodes() to get the list of nodes projected into the slot. This is useful for building container components that need to adapt their behavior based on what content the parent provides — for example, a tab container that counts its tab panels, or a toolbar that adjusts its layout based on how many buttons are slotted in.
Note that slotchange fires when the assigned nodes change, not when the content inside those nodes changes. If a parent updates a property inside a slotted child component, that does not trigger slotchange. It only fires when elements are added to or removed from the slot.
Section Notes
Here is a summary of the key ideas from this post:
- Templates are the HTML files in your LWC. They are compiled into efficient DOM update instructions.
- Data binding in LWC is one-way by default. Use curly braces
{property}to reference JavaScript properties in your markup. You cannot embed arbitrary expressions. - Getters are the solution when you need computed values in templates. They act like properties but run logic behind the scenes.
- Setters paired with
@apigetters let you intercept and transform incoming property values from parent components. - lwc:if / lwc:elseif / lwc:else replaced the older
if:trueandif:falsedirectives. They must be on sibling elements. - for:each iterates over arrays. Always provide a unique
key. Useiterator:when you needfirstandlastflags. - The render() method lets you swap entire templates. Use it for drastically different layouts, not for simple show/hide logic.
- Composition is building complex UIs from small, focused components. Data flows down through properties, events flow up through
dispatchEvent. - lwc:spread passes an object of attributes to a child component in one shot, reducing boilerplate in parent templates.
- Slots let parents inject markup into children. Use unnamed slots for a single insertion point and named slots for multiple.
- The slotchange event fires when the elements assigned to a slot change, letting the child react to parent content additions and removals.
PROJECT: Create an LWC That Utilizes Data Binding, Dynamic Template Rendering, and Composition
Build a Team Dashboard that brings together everything from this post. The requirements:
Parent Component — teamDashboard
- Maintains an array of team member objects, each with
id,name,role,isActive, anddepartment. - Renders a header showing the total count of members using a getter.
- Uses
lwc:ifto show a loading spinner while data is being fetched and an empty state message when no members exist. - Uses
for:eachto iterate over the members array and render a child component for each one. - Includes a toggle button that switches between card view and list view using the
render()method with two separate HTML templates.
Child Component — memberCard
- Accepts
name,role,isActive, anddepartmentas@apiproperties. - Uses a getter to compute a status badge class (green for active, red for inactive).
- Uses a setter on
nameto capitalize the first letter of each word. - Dispatches a
removecustom event when a remove button is clicked. - Contains a named slot called
actionsso the parent can inject custom buttons.
Parent Template Usage
<template for:each={members} for:item="member">
<c-member-card
key={member.id}
lwc:spread={member}
onremove={handleRemove}>
<lightning-button
slot="actions"
label="View Profile"
onclick={handleViewProfile}>
</lightning-button>
</c-member-card>
</template>
Stretch Goals
- Add a
slotchangehandler inmemberCardthat logs how many action buttons were provided. - Add a filter bar that uses
lwc:ifandlwc:elseifto show different filter states (all, active only, inactive only). - Use
lwc:spreadto pass filter configuration to a child filter component.
This project ties together data binding, getters, setters, conditional rendering, iteration, template swapping, composition, spread, and slots — everything covered in this post. In Part 80, we will look at the LWC lifecycle hooks in detail and understand the full sequence from construction to disconnection. See you there.