Part 78: LWC Lifecycle Hooks and Decorators
If you have been following the LWC topic from Part 72 onward, you have already built components, worked with HTML templates, handled data binding, and started thinking about how components communicate. All of that is built on top of two foundational systems that we need to understand deeply: lifecycle hooks and decorators.
Lifecycle hooks let you run code at specific moments in a component’s life — when it is created, when it is inserted into the page, when it finishes rendering, and when it is removed. Decorators let you mark properties with special behavior — exposing them to parent components, wiring them to Salesforce data, or making them reactive so the template updates when they change.
These two systems are the backbone of every non-trivial LWC. If you understand them well, everything else in the framework clicks into place. If you do not, you will constantly be fighting timing issues, stale data, and confusing re-renders.
This post covers:
- What are Lifecycle Hooks — The concept and why they exist.
- All LWC Lifecycle Hooks — constructor, connectedCallback, renderedCallback, disconnectedCallback, and errorCallback.
- What are Decorators — The concept and how they differ from regular properties.
- The @api Decorator — Exposing properties and methods to parent components.
- The @wire Decorator — Connecting to Salesforce data reactively.
- The @track Decorator — Making complex objects reactive.
- PROJECT — A complete component that uses all three decorators and the connectedCallback hook.
Let’s start with the lifecycle.
What Are Lifecycle Hooks?
A lifecycle hook is a method that the framework calls automatically at a specific point in a component’s existence. You do not call these methods yourself. Instead, you define them in your component class, and the LWC engine invokes them at the right time.
Think of it like event handlers for the component itself. The browser creates your component, inserts it into the DOM, re-renders it when data changes, and eventually removes it. At each of those moments, the framework checks whether you have defined the corresponding hook and calls it if you have.
This is not unique to LWC. React has componentDidMount and useEffect. Angular has ngOnInit and ngOnDestroy. Vue has mounted and unmounted. The concept is the same everywhere — the framework gives you clearly defined entry points to run your code at the right time.
In LWC, there are five lifecycle hooks you need to know.
An Overview of All LWC Lifecycle Hooks
constructor()
The constructor is the very first thing that runs when your component is created. It runs before the component is attached to the DOM, before any properties are set by parent components, and before the template is rendered.
import { LightningElement } from 'lwc';
export default class MyComponent extends LightningElement {
constructor() {
super(); // Required — must be the first line
this.counter = 0;
console.log('Constructor: component instance created');
}
}
There are strict rules for the constructor:
- You must call
super()as the first statement. This calls the parent class constructor inLightningElement. If you forget this, you get a runtime error. - You cannot access
this.templateor any child elements. The DOM does not exist yet. - You cannot inspect attributes or public properties set by the parent. They have not been assigned yet.
- You should not dispatch events. Nobody is listening yet because the component is not in the DOM.
The constructor is best used for initializing simple default values on instance properties. If you need to do anything that involves the DOM, data fetching, or interacting with the outside world, use connectedCallback instead.
connectedCallback()
This is the hook you will use most often. It fires when the component is inserted into the DOM. At this point, the component is part of the page, public properties set by the parent are available, and you can start doing real work.
import { LightningElement, api } from 'lwc';
export default class AccountDetail extends LightningElement {
@api recordId;
accountData;
connectedCallback() {
console.log('ConnectedCallback: component is in the DOM');
console.log('Record ID from parent:', this.recordId);
this.loadAccountData();
}
loadAccountData() {
// Fetch data, set up subscriptions, etc.
}
}
Key facts about connectedCallback:
- It runs after the constructor and after public properties (
@api) are set. - It runs before the first render. The template has not been stamped into the DOM yet when this fires.
- It can fire more than once if the component is removed from the DOM and re-inserted. This is uncommon but possible.
- This is the right place to fetch data imperatively, subscribe to message channels, set up intervals or event listeners, and read public property values.
If connectedCallback can fire more than once, you should guard against duplicate subscriptions or duplicate fetch calls. A simple boolean flag works well for this.
connectedCallback() {
if (!this.isInitialized) {
this.isInitialized = true;
this.subscribeToChannel();
}
}
renderedCallback()
This hook fires after every render cycle. That means it runs after the initial render and again every time the component re-renders because reactive data changed.
import { LightningElement } from 'lwc';
export default class ChartComponent extends LightningElement {
hasRendered = false;
renderedCallback() {
console.log('RenderedCallback: DOM has been updated');
if (!this.hasRendered) {
this.hasRendered = true;
this.initializeChart();
}
}
initializeChart() {
const container = this.template.querySelector('.chart-container');
// Initialize a third-party charting library against this DOM node
}
}
This is the only hook where the DOM is fully rendered and you can safely query it with this.template.querySelector. But because it fires on every re-render, you need to be careful:
- Do not modify reactive properties inside
renderedCallbackwithout a guard. Changing a reactive property triggers a re-render, which triggersrenderedCallbackagain, which changes the property, which triggers another re-render — infinite loop. - Always use a flag like
hasRenderedif you only want initialization code to run once. - Use this hook for DOM manipulation that depends on rendered elements — initializing third-party libraries, measuring element dimensions, or setting focus on an input.
disconnectedCallback()
This fires when the component is removed from the DOM. It is your cleanup hook.
import { LightningElement } from 'lwc';
export default class PollingComponent extends LightningElement {
intervalId;
connectedCallback() {
this.intervalId = setInterval(() => {
this.checkForUpdates();
}, 30000);
}
disconnectedCallback() {
console.log('DisconnectedCallback: cleaning up');
clearInterval(this.intervalId);
}
checkForUpdates() {
// Poll for new data
}
}
If you subscribe to a Lightning Message Service channel in connectedCallback, unsubscribe in disconnectedCallback. If you set up a setInterval, clear it here. If you added a window event listener, remove it here. Failing to clean up is a common source of memory leaks and ghost behavior in LWC applications.
errorCallback(error, stack)
This is a special hook that acts as an error boundary. When a child component throws an error during rendering or in a lifecycle hook, the error bubbles up to the nearest parent that defines errorCallback.
import { LightningElement } from 'lwc';
export default class ErrorBoundary extends LightningElement {
hasError = false;
errorMessage = '';
errorCallback(error, stack) {
console.error('Error caught:', error.message);
console.error('Stack:', stack);
this.hasError = true;
this.errorMessage = error.message;
}
}
The corresponding template might look like this:
<template>
<template if:true={hasError}>
<div class="error-panel">
<p>Something went wrong: {errorMessage}</p>
</div>
</template>
<template if:false={hasError}>
<slot></slot>
</template>
</template>
Important details:
errorCallbackonly catches errors from child components, not from the component itself.- It receives two arguments: the error object and a string representation of the stack trace.
- It is the LWC equivalent of React’s error boundaries. Use it to wrap sections of your page so that one broken component does not take down the entire UI.
The Lifecycle Flow
Putting it all together, the order is: constructor then connectedCallback then renderedCallback. When data changes, renderedCallback fires again. When the component is removed, disconnectedCallback fires. And errorCallback fires whenever a descendant throws.
Understanding this order is critical for debugging. If your code runs in the constructor and tries to read a public property, it will be undefined. If your code runs in connectedCallback and tries to query the DOM, it will find nothing because the template has not rendered yet. Right hook, right time.
What Are Decorators?
Decorators are a JavaScript feature (currently a stage 3 proposal in the broader language, but fully supported in LWC) that let you annotate a class field or method to change its behavior. In LWC, decorators tell the framework how to treat a property.
A regular class property in an LWC is private by default. It is not exposed to parent components, it is not connected to any data source, and changes to it may or may not trigger a re-render depending on how it is used. Decorators change that default behavior.
LWC provides three decorators: @api, @wire, and @track. Each one is imported from the lwc module.
import { LightningElement, api, wire, track } from 'lwc';
You can only use one decorator per property. You cannot stack @api and @track on the same field. Let’s look at each one.
The @api Decorator
The @api decorator makes a property or method public. This means parent components can set the property’s value from their template, and external code can call the method.
Public Properties
// childComponent.js
import { LightningElement, api } from 'lwc';
export default class ChildComponent extends LightningElement {
@api title = 'Default Title';
@api recordId;
@api variant = 'standard';
}
The parent can now pass values:
<!-- parentComponent.html -->
<template>
<c-child-component
title="Account Details"
record-id={currentRecordId}
variant="compact">
</c-child-component>
</template>
Notice the naming convention. In JavaScript, you use camelCase (recordId). In HTML, you use kebab-case (record-id). The framework handles the conversion.
A few rules to know:
@apiproperties are reactive. When the parent changes the value, the child re-renders automatically.- The child component must not modify its own
@apiproperty. It is owned by the parent. If you need a local copy to work with, copy it into a private property inconnectedCallbackor use a getter. @apiproperties can have default values, as shown above. The default is used when the parent does not pass a value.
Public Methods
You can also expose methods with @api:
// modalComponent.js
import { LightningElement, api } from 'lwc';
export default class ModalComponent extends LightningElement {
isVisible = false;
@api
open() {
this.isVisible = true;
}
@api
close() {
this.isVisible = false;
}
}
The parent can call these methods by querying the child:
// parentComponent.js
handleOpenModal() {
this.template.querySelector('c-modal-component').open();
}
Public methods are useful for imperative actions that do not map well to data binding — opening modals, resetting forms, triggering animations, or scrolling to a specific position.
The @wire Decorator
The @wire decorator connects a property or function to a Salesforce data source. It is the reactive, declarative way to fetch data in LWC. When the input parameters change, the wire adapter automatically re-fetches the data and the component re-renders.
Wiring to a Property
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import INDUSTRY_FIELD from '@salesforce/schema/Account.Industry';
export default class AccountCard extends LightningElement {
@api recordId;
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, INDUSTRY_FIELD] })
account;
get accountName() {
return getFieldValue(this.account.data, NAME_FIELD);
}
get accountIndustry() {
return getFieldValue(this.account.data, INDUSTRY_FIELD);
}
}
When you wire to a property, the framework sets that property to an object with two fields: data and error. If the call succeeds, data contains the result and error is undefined. If it fails, error contains the error and data is undefined.
The $recordId syntax with the dollar sign prefix is how you make wire parameters reactive. When this.recordId changes, the wire re-fires automatically.
Wiring to a Function
Sometimes you need more control over what happens when data arrives. You can wire to a function instead:
import { LightningElement, api, wire } from 'lwc';
import getContacts from '@salesforce/apex/ContactController.getContacts';
export default class ContactList extends LightningElement {
@api recordId;
contacts = [];
error;
@wire(getContacts, { accountId: '$recordId' })
wiredContacts({ data, error }) {
if (data) {
this.contacts = data.map(contact => ({
...contact,
fullName: `${contact.FirstName} ${contact.LastName}`
}));
this.error = undefined;
} else if (error) {
this.error = error;
this.contacts = [];
}
}
}
Wiring to a function gives you a chance to transform the data, combine it with other sources, or run additional logic before storing it. The function receives the same { data, error } shape.
When to Use @wire vs Imperative Apex
Use @wire when you want data to load automatically and stay in sync with reactive parameters. Use imperative Apex calls (calling the method directly with async/await) when you need to control exactly when the call happens — for example, after a button click or form submission. We will cover imperative Apex in a later post.
The @track Decorator
The @track decorator is the one that confuses people most, partly because its role has changed over time. Here is the current state.
Primitive Reactivity Is Automatic
As of the Spring ‘20 release, all fields in an LWC class are reactive by default for primitive values. If you have a string, number, or boolean property and you change it, the template re-renders. You do not need @track for this.
import { LightningElement } from 'lwc';
export default class Counter extends LightningElement {
count = 0; // No decorator needed — primitives are reactive
increment() {
this.count += 1; // Template updates automatically
}
}
Where @track Is Still Needed
The default reactivity only tracks reassignment. It does not track mutations inside objects or arrays. If you push an item into an array or change a nested property on an object, the framework does not see the change and the template does not update.
import { LightningElement, track } from 'lwc';
export default class TodoList extends LightningElement {
@track todos = [];
addTodo() {
// With @track, pushing into the array triggers a re-render
this.todos.push({
id: Date.now(),
label: 'New task',
completed: false
});
}
toggleTodo(event) {
const todoId = parseInt(event.target.dataset.id, 10);
const todo = this.todos.find(t => t.id === todoId);
if (todo) {
// With @track, mutating a nested property triggers a re-render
todo.completed = !todo.completed;
}
}
}
Without @track, neither push nor the nested property change would cause the template to update. With @track, the framework deeply observes the object and catches these mutations.
The Alternative: Reassignment
Some developers prefer to avoid @track entirely and use reassignment instead. Instead of mutating the array, you create a new one:
addTodo() {
this.todos = [...this.todos, {
id: Date.now(),
label: 'New task',
completed: false
}];
}
Because you are reassigning this.todos to a brand new array, the default reactivity picks it up and the template re-renders. This is the spread operator pattern, and it works well for simpler cases. The trade-off is that you are creating new array and object references on every change, which can matter for very large data sets.
My recommendation: use @track when you are working with complex, deeply nested objects that you mutate in place. Use reassignment when your data structures are simple or when you prefer an immutable style. Either approach works.
PROJECT: All Three Decorators and connectedCallback
Let’s build a component called contactExplorer that demonstrates all three decorators and the connectedCallback lifecycle hook in one place. This component will:
- Accept a record ID from its parent (
@api). - Fetch contacts related to an account using a wire adapter (
@wire). - Maintain a local filter state with deep object tracking (
@track). - Initialize filter defaults in
connectedCallback.
The Apex Controller
First, the Apex class that our wire will call:
public with sharing class ContactExplorerController {
@AuraEnabled(cacheable=true)
public static List<Contact> getContactsByAccount(String accountId) {
return [
SELECT Id, FirstName, LastName, Email, Phone, Title
FROM Contact
WHERE AccountId = :accountId
ORDER BY LastName ASC
LIMIT 50
];
}
}
The cacheable=true annotation is required for any Apex method used with @wire.
The JavaScript File
// contactExplorer.js
import { LightningElement, api, wire, track } from 'lwc';
import getContactsByAccount from '@salesforce/apex/ContactExplorerController.getContactsByAccount';
export default class ContactExplorer extends LightningElement {
// @api — public property set by the parent component
@api recordId;
// @track — deeply reactive filter object
@track filters = {
searchTerm: '',
showOnlyWithEmail: false,
sortField: 'LastName'
};
// Private properties — primitives are reactive by default
allContacts = [];
error;
isInitialized = false;
// Lifecycle hook — runs when component enters the DOM
connectedCallback() {
if (!this.isInitialized) {
this.isInitialized = true;
console.log('ContactExplorer initialized for record:', this.recordId);
// Set default filters based on context
this.filters.searchTerm = '';
this.filters.showOnlyWithEmail = false;
this.filters.sortField = 'LastName';
}
}
// @wire — reactively fetch contacts when recordId changes
@wire(getContactsByAccount, { accountId: '$recordId' })
wiredContacts({ data, error }) {
if (data) {
this.allContacts = data;
this.error = undefined;
} else if (error) {
this.error = error;
this.allContacts = [];
}
}
// Computed property — applies filters to the contact list
get filteredContacts() {
let result = [...this.allContacts];
// Apply search filter
if (this.filters.searchTerm) {
const term = this.filters.searchTerm.toLowerCase();
result = result.filter(contact =>
(contact.FirstName && contact.FirstName.toLowerCase().includes(term)) ||
(contact.LastName && contact.LastName.toLowerCase().includes(term)) ||
(contact.Email && contact.Email.toLowerCase().includes(term))
);
}
// Apply email filter
if (this.filters.showOnlyWithEmail) {
result = result.filter(contact => contact.Email);
}
// Apply sort
result.sort((a, b) => {
const fieldA = (a[this.filters.sortField] || '').toLowerCase();
const fieldB = (b[this.filters.sortField] || '').toLowerCase();
return fieldA.localeCompare(fieldB);
});
return result;
}
get contactCount() {
return this.filteredContacts.length;
}
get hasContacts() {
return this.filteredContacts.length > 0;
}
get hasError() {
return this.error !== undefined;
}
// Event handlers that mutate the @track filter object
handleSearchChange(event) {
this.filters.searchTerm = event.target.value;
}
handleEmailFilterChange(event) {
this.filters.showOnlyWithEmail = event.target.checked;
}
handleSortChange(event) {
this.filters.sortField = event.detail.value;
}
}
The HTML Template
<!-- contactExplorer.html -->
<template>
<lightning-card title="Contact Explorer" icon-name="standard:contact">
<!-- Filter Controls -->
<div class="slds-p-horizontal_medium slds-p-bottom_small">
<div class="slds-grid slds-gutters">
<div class="slds-col slds-size_1-of-3">
<lightning-input
label="Search Contacts"
type="search"
value={filters.searchTerm}
onchange={handleSearchChange}
placeholder="Search by name or email...">
</lightning-input>
</div>
<div class="slds-col slds-size_1-of-3">
<lightning-combobox
label="Sort By"
value={filters.sortField}
options={sortOptions}
onchange={handleSortChange}>
</lightning-combobox>
</div>
<div class="slds-col slds-size_1-of-3 slds-flex slds-align-bottom">
<lightning-input
type="checkbox"
label="Only show contacts with email"
checked={filters.showOnlyWithEmail}
onchange={handleEmailFilterChange}>
</lightning-input>
</div>
</div>
</div>
<!-- Results -->
<div class="slds-p-horizontal_medium">
<template if:true={hasError}>
<div class="slds-text-color_error slds-p-around_medium">
Error loading contacts. Please try again.
</div>
</template>
<template if:true={hasContacts}>
<p class="slds-p-bottom_small slds-text-body_small">
Showing {contactCount} contacts
</p>
<lightning-datatable
key-field="Id"
data={filteredContacts}
columns={columns}
hide-checkbox-column>
</lightning-datatable>
</template>
<template if:false={hasContacts}>
<div class="slds-p-around_medium slds-text-align_center">
<p>No contacts match your filters.</p>
</div>
</template>
</div>
</lightning-card>
</template>
The Meta File
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
</targets>
</LightningComponentBundle>
What This Demonstrates
This single component exercises the three core concepts from this post:
- @api —
recordIdis set by the parent (the Lightning record page passes it automatically). The component never modifies it. - @wire —
getContactsByAccountfires automatically whenrecordIdis available and re-fires if it changes. The component does not manually call the Apex method. - @track —
filtersis a complex object with three nested properties. Because it is decorated with@track, mutatingfilters.searchTermorfilters.showOnlyWithEmaildirectly causes the computedfilteredContactsgetter to re-evaluate and the template to update. - connectedCallback — Initializes the component state once when it enters the DOM, with a guard flag to prevent re-initialization if the component is re-inserted.
To deploy this, drop the four files (.js, .html, .js-meta.xml, and the Apex class) into your project, push to your org, and drag the component onto an Account record page in Lightning App Builder. Open an account with contacts and you will see the explorer load, filter, and sort in real time.
Wrapping Up
Lifecycle hooks and decorators are the two systems you will interact with in every LWC you build. The hooks give you control over timing — when to initialize, when to fetch, when to manipulate the DOM, and when to clean up. The decorators give you control over data flow — what is public, what is wired to Salesforce, and what is deeply reactive.
The key takeaways:
- Use constructor only for simple default values. Call
super()first. - Use connectedCallback for initialization logic — data fetching, subscriptions, reading public properties.
- Use renderedCallback sparingly and always with a guard flag to avoid infinite loops.
- Use disconnectedCallback to clean up anything you set up in
connectedCallback. - Use errorCallback in wrapper components to create error boundaries.
- Use @api for properties and methods that parents need to access.
- Use @wire for declarative, reactive data fetching from Salesforce.
- Use @track when you need to deeply observe mutations on objects and arrays.
In the next post, we will look at how LWC components communicate with each other — parent to child, child to parent, and across unrelated components using events and the Lightning Message Service. That is where all of these hooks and decorators start working together in a multi-component architecture. See you there.