
Introduction : Creating durable and resilient lightning web components
Lightning Web Components (LWC) is a modern web component framework for building reusable UI elements for Salesforce. As with any UI development, writing scalable and maintainable code is crucial when building enterprise LWC applications. In this comprehensive guide, we will explore key design patterns and best practices for developing robust and sustainable Lightning web components.
Specifically, we will cover:
- Separation of concerns and modular architecture
- Encapsulation of business logic
- Loose coupling and reusability
- Inter-component communication
- Declarative rendering and styling
- Unit testing approach
By the end of this guide, you will have a solid understanding of architecting LWC code for scalability along with sample examples based on that you can create your components.
☞ Principles of Scalable LWC Code
Before diving into specific design patterns, let’s outline some overarching principles that lend themselves to scalable LWC development:
💥 Separation of Concerns
Each component should focus on one single responsibility and strictly encapsulate its behavior and dependencies from other components. Adhering to the single responsibility principle is key for both reusability and maintainability.
For example, a data table component for displaying rows of records should not contain logic for fetching or processing data. That should be handled externally, and data simply passed in:
// DataTable.js
import { LightningElement, api } from 'lwc';
export default class DataTable extends LightningElement {
@api rows = [];
@api get columns() {
// columns derived from row data
}
}
// ParentComponent.js
import { LightningElement } from 'lwc';
import getData from '/dataService';
export default class ParentComponent extends LightningElement {
data = [];
async connectedCallback() {
this.data = await getData();
}
}
// Markup
<c-parent-component>
<c-data-table rows={data}></c-data-table>
</c-parent-component>
➡ Encapsulation
Implementation details of a component should be hidden to avoid tight coupling with other components. Complex logic and API calls should be abstracted into a service layer whenever possible.
➡ Loose Coupling
Components should have minimal dependencies on concrete details of other components or services. This reduces churn from changes and enables easier substitution of one component for another.
➡ Declarative Rendering
Components should leverage external configuration for rendering via inputs, slots, and custom CSS properties rather than explicit rendering logic:
// Header.js
import { LightningElement, api } from 'lwc';
export default class Header extends LightningElement {
@api title;
@api iconName;
@api backgroundColor = 'grey';
}
// Usage
<c-header
title="Sales Report"
icon-name="standard:account"
background-color="red">
</c-header>
💥 Modular Architecture
Now let’s explore a modular architecture optimized for scalable LWC development.
➡ Component Hierarchy
Structure components in a hierarchy from generic to specific:
- Base components (e.g. Button, Icon, Card)
- Shared components (e.g. PageLayout, Notification, LinkMenu)
- Domain components (e.g. AccountCard, ContactTable)
Child components should only ever reference parent or base components, never siblings or cousins. This reduces dependencies between components.
➡ Unidirectional Data Flow
Data in our optimal architecture should flow in one direction down:
- Parent to child via properties
- Child to parent via events
- Services to components via methods
This prevents convoluted data management logic.
➡ Encapsulating Business Logic
Business logic like data access and processing should be extracted out from visual components into a dedicated service layer. These services then expose a simplified interface for components to leverage internally.
// contactService.js
import { LightningElement } from 'lwc';
const CONTACTS_ENDPOINT = '/contacts';
export const getContactList = async () => {
try {
const results = await fetch(CONTACTS_ENDPOINT);
return await results.json();
} catch(error) {
throw error;
}
}
export const getContactDetails = async (id) => {
try {
const result = await fetch(CONTACTS_ENDPOINT + '/' + id);
return await result.json();
} catch(error) {
throw error;
}
}
// ContactViewer.js
import { LightningElement } from 'lwc';
import { getContactList, getContactDetails } from 'contactService';
export default class ContactViewer extends LightningElement {
contacts = [];
selectedContact;
async connectedCallback() {
this.contacts = await getContactList();
}
handleContactSelect(event) {
const id = event.detail;
this.selectedContact = await getContactDetails(id);
}
}
This keeps components simple and focused on UI logic while leveraging services for the heavy lifting.
💥 Inter-Component Communication
Properties and Events
Parent to child data flow should occur via properties, child to parent via events:
// Child Component
import { LightningElement, api } from 'lwc';
export default ContactTile extends LightningElement {
@api contact;
handleSelect() {
const selectEvent = new CustomEvent('select', {
detail: this.contact.Id
});
this.dispatchEvent(selectEvent);
}
}
// Parent Component
<c-contact-tile
contact={selectedContact}
onselect={handleSelection}>
</c-contact-tile>
Lightning Message Service
For loosely coupled communication between components in isolation, leverage the Lightning Message Service.
// Publisher
import { LightningElement } from 'lwc';
import { publish } from 'lightning/messageService';
export default class SearchComponent extends LightningElement {
handleSearch() {
const message = {
results: //...
};
publish(this.MESSAGE_CHANNEL, message);
}
}
// Subscriber
import { LightningElement } from 'lwc';
import { subscribe } from 'lightning/messageService';
export default class ResultsComponent extends LightningElement {
subscription = null;
connectedCallback() {
this.subscription = subscribe(
this.MESSAGE_CHANNEL,
this.handleMessage
);
}
handleMessage(message) {
this.results = message.results;
}
}
💥 Reusable Component Design
Slots
Leverage slots to make components customizable and composable:
// Tile.js
import { LightningElement, api } from 'lwc';
export default class Tile extends LightningElement {
@api title;
@api icon;
@slot header {
// custom header content
}
@slot footer {
// custom footer content
}
}
// Usage
<c-tile>
<span slot="header">Custom Tile</span>
<p>Body Content</p>
<span slot="footer">Footer Information</span>
</c-tile>
Wrappers
Create wrapper components to standardize behavior and layout around other components:
// TileWrapper.js
import { LightningElement } from 'lwc';
export default class TileWrapper extends LightningElement {
@api src;
render() {
return <c-tile>
<img slot="media" src={this.src}></img>
</c-tile>
}
}
// Usage
<c-tile-wrapper
src="/image.jpg">
</c-tile-wrapper>
CSS Properties
Expose styling hooks for components to override styles without touching component implementation:
// Button.js
import { LightningElement, api } from 'lwc';
export default class Button extends LightningElement {
@api customColor;
get customStyle() {
return `color: ${this.customColor}`;
}
}
// Usage
<c-button custom-color="deeppink">
Click Me
</c-button>
💥 Testing Approach
Comprehensive testing is critical for long term maintainability.
Unit Tests
Cover individual functions and methods with unit tests:
// contactService.test.js
it('gets contact list', async () => {
const results = await getContactList();
expect(results.length).toBeGreaterThan(0);
});
it('handles contact request error', async () => {
// Mock fetch error
jest.spyOn(global, 'fetch').mockRejectedValue('API Error');
await expect(getContactsList()).rejects.toEqual('API Error');
})
Integration Tests
Test component interactions and data flows:
// ContactViewer.test.js
const VIEWER = 'c-contact-viewer';
it('shows selected contact when tile clicked', () => {
const TILE = `${VIEWER} c-contact-tile`;
const viewerEl = createElement(VIEWER, { is: VIEWER });
document.body.appendChild(viewerEl);
const tileEl = viewerEl.shadowRoot.querySelector(TILE);
tileEl.click();
return Promise.resolve().then(() => {
expect(viewerEl.selectedContact).toBeDefined();
})
});
UI Tests
Validate real experience with end-to-end UI tests.
Conclusion
In this guide we covered several key patterns for developing scalable LWC applications:
- Strict separation of concerns between components
- Encapsulating business logic in services
- Unidirectional data flow models
- Loosely coupled communication between components
- Declarative rendering and styling hooks
- Reusable wrappers and slot based composition
- Comprehensive testing strategy
Applying these patterns will lend to cleaner component abstractions, more streamlined data flows, improved reusability, simpler integration, and reduced maintenance overhead in the long run.
Explore lwc related post / content here
For more join, our telegram channel, Subscribe SFDCLesson YouTube channel.
About the blog
SFDCLessons is a blog where you can find various Salesforce tutorials and tips that we have written to help beginners and experienced developers alike. we also share my experience and knowledge on Salesforce best practices, troubleshooting, and optimization. Don’t forget to follow us on:
Newsletter
Subscribe to our email newsletter to be notified when a new post is published.
