name: sfb2b-lwc description: LWC development best practices for B2B Commerce storefront including component architecture, templates, styling, state management, testing, performance optimization, and common pitfalls for {{clientName}}
B2B Commerce LWC Development Guide
Component Architecture
B2B Commerce Component Hierarchy
Standard Structure
{{projectPrefixKebab}}-storefront (top-level page wrapper)
├── {{projectPrefixKebab}}-header (site header)
│ ├── {{projectPrefixKebab}}-search-bar
│ ├── {{projectPrefixKebab}}-nav-menu
│ └── {{projectPrefixKebab}}-cart-icon
├── {{projectPrefixKebab}}-main-content
│ ├── {{projectPrefixKebab}}-sidebar (category filters or nav)
│ └── {{projectPrefixKebab}}-page-content
│ ├── {{projectPrefixKebab}}-product-listing (PCP)
│ ├── {{projectPrefixKebab}}-product-detail (PDP)
│ ├── {{projectPrefixKebab}}-checkout-flow
│ └── {{projectPrefixKebab}}-account-page
└── {{projectPrefixKebab}}-footer
Component Communication Pattern
- Parent -> Child: properties (passed as @api decorated properties)
- Child -> Parent: events (dispatch custom events)
- Sibling communication: pub-sub (Lightning Message Service or custom event bus)
- Shared state: data service (singleton Apex controller or LWC data adapter)
Slot-Based Composition
Benefit: Flexible template composition without hardcoding child components
Example: Product Card with Slots
<!-- {{projectPrefixKebab}}-product-card.html -->
<template>
<div class="product-card">
<slot name="image"></slot>
<slot name="title"></slot>
<slot name="price"></slot>
<slot name="actions"></slot>
</div>
</template>
Usage in Parent
<!-- {{projectPrefixKebab}}-product-listing.html -->
<template for:each="{products}" for:item="product">
<{{projectPrefixKebab}}-product-card key="{product.id}" product="{product}">
<{{projectPrefixKebab}}-product-image slot="image" product="{product}"></{{projectPrefixKebab}}-product-image>
<{{projectPrefixKebab}}-product-title slot="title" product="{product}"></{{projectPrefixKebab}}-product-title>
<{{projectPrefixKebab}}-product-price slot="price" product="{product}"></{{projectPrefixKebab}}-product-price>
<{{projectPrefixKebab}}-add-to-cart-button slot="actions" product="{product}"></{{projectPrefixKebab}}-add-to-cart-button>
</{{projectPrefixKebab}}-product-card>
</template>
Data Service Pattern: Wire vs Imperative
Wire Adapter Pattern (Reactive)
- Automatic refresh when inputs change
- Best for: static queries, stable dependencies
- Example:
@wire(getProduct, { productId: '$productId' }) - Limitation: watch all inputs, not selective refresh
Imperative Pattern (Explicit Control)
- Call service manually, handle refresh logic
- Best for: user-triggered actions, complex logic, performance optimization
- Example: click button -> call service -> update state
- Advantage: fine-grained control, can skip updates
Decision Matrix
Use Wire When | Use Imperative When
- Page loads, show data | - User triggers action
- Input stable, no change | - Conditional fetching
- Simple presentation | - Complex error handling
| - Performance: skip updates
Example: Product Detail with Hybrid Approach
// Wire: auto-load product definition
@wire(getProduct, { productId: '$productId' })
product;
// Imperative: call pricing on demand
handleLoadPricing() {
getPricing({ productId: this.productId })
.then(pricing => this.pricing = pricing)
.catch(error => this.showError(error));
}
Event Bubbling and Pub-Sub
Event Bubbling
- Child dispatch custom event -> propagates up to parent
- Use for close parent-child relationships
- Example: cart line item delete button -> dispatch "itemdeleted" event -> cart listener removes item
Pub-Sub (Lightning Message Service)
- Decoupled communication between unrelated components
- Publish event -> all subscribers notified
- Best for: app-wide state changes (cart updated, user logged in)
- Avoid: tight coupling, overuse (can cause performance issues)
Example: Cart Update Notification
// Component A: delete cart item
import { publish, MessageContext } from 'lightning/messageService';
import CART_UPDATED from '@salesforce/messageChannel/CartUpdated__c';
deleteItem() {
// Delete logic...
publish(this.messageContext, CART_UPDATED, { cartId: this.cartId });
}
// Component B: listen for cart updates
import { subscribe, MessageContext } from 'lightning/messageService';
import CART_UPDATED from '@salesforce/messageChannel/CartUpdated__c';
connectedCallback() {
this.subscription = subscribe(this.messageContext, CART_UPDATED, (message) => {
this.refreshCart(message.cartId);
});
}
B2B Commerce Components
Extending Standard Commerce Components
Product Card Component
Standard component provides: image, title, price display Extension opportunities: add custom badges, promotions, quick-view modal
// {{projectPrefixKebab}}-product-card.js
import { LightningElement, api } from 'lwc';
export default class {{projectPrefixPascal}}ProductCard extends LightningElement {
@api product;
@api showPromoBadge = true;
get isPromoProduct() {
return this.product.isOnPromotion;
}
handleQuickView() {
this.dispatchEvent(
new CustomEvent('quickview', {
detail: { productId: this.product.id },
}),
);
}
}
Cart Component
Standard component provides: line item list, update quantity, remove item Extension opportunities: add discount code field, shipping estimate, save for later
// {{projectPrefixKebab}}-cart-summary.js
import { LightningElement, api, wire } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import CART_FIELDS from '@salesforce/apex/CartController.getCartFields';
export default class {{projectPrefixPascal}}CartSummary extends LightningElement {
@api cartId;
@wire(getRecord, { recordId: '$cartId', fields: CART_FIELDS })
cartData;
get total() {
return this.cartData?.data?.fields?.TotalAmount?.value ?? 0;
}
get taxAmount() {
return this.cartData?.data?.fields?.TaxAmount?.value ?? 0;
}
}
Checkout Component
Standard component provides: step navigation, form validation Extension opportunities: custom approval step, delivery location selector, payment integration
Component Templates
Product Detail Page Component
// {{projectPrefixKebab}}-product-detail.js
import { LightningElement, api, wire, track } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import getPricingInfo from '@salesforce/apex/PricingController.getPricing';
import getProductImages from '@salesforce/apex/ProductController.getImages';
const PRODUCT_FIELDS = [
'Product2.Id',
'Product2.Name',
'Product2.Description',
'Product2.ProductCode',
'Product2.StockKeepingUnit',
];
export default class {{projectPrefixPascal}}ProductDetail extends LightningElement {
@api productId;
@track quantity = 1;
@track selectedVariant = null;
@track pricingInfo;
@track productImages;
isLoading = false;
errorMessage;
@wire(getRecord, { recordId: '$productId', fields: PRODUCT_FIELDS })
product;
@wire(getPricingInfo, { productId: '$productId' })
wiredPricing({ error, data }) {
if (data) {
this.pricingInfo = data;
this.errorMessage = null;
} else if (error) {
this.errorMessage = 'Unable to load pricing information';
}
}
connectedCallback() {
this.loadProductImages();
}
loadProductImages() {
getProductImages({ productId: this.productId })
.then((images) => {
this.productImages = images;
})
.catch((error) => {
console.error('Error loading images:', error);
});
}
handleQuantityChange(event) {
this.quantity = parseInt(event.target.value, 10);
}
handleVariantSelect(event) {
this.selectedVariant = event.detail;
}
async handleAddToCart() {
this.isLoading = true;
try {
const result = await addProductToCart({
productId: this.productId,
quantity: this.quantity,
variantId: this.selectedVariant?.id,
});
this.dispatchEvent(
new CustomEvent('cartitemadded', {
detail: result,
}),
);
} catch (error) {
this.errorMessage = error.message;
} finally {
this.isLoading = false;
}
}
get unitPrice() {
return this.pricingInfo?.unitPrice ?? 0;
}
get totalPrice() {
return this.unitPrice * this.quantity;
}
get isOutOfStock() {
return this.pricingInfo?.availableQuantity === 0;
}
}
<!-- {{projectPrefixKebab}}-product-detail.html -->
<template>
<div class="product-detail">
<template if:true="{isLoading}">
<lightning-spinner></lightning-spinner>
</template>
<template if:false="{isLoading}">
<div class="detail-container">
<!-- Product Images -->
<div class="images-section">
<{{projectPrefixKebab}}-product-gallery images="{productImages}"></{{projectPrefixKebab}}-product-gallery>
</div>
<!-- Product Info -->
<div class="info-section">
<h1>{product.fields.Name.value}</h1>
<p class="sku">SKU: {product.fields.ProductCode.value}</p>
<!-- Pricing -->
<div class="pricing-section">
<span class="unit-price">${unitPrice}</span>
<span class="total-price">Total: ${totalPrice}</span>
</div>
<!-- Variant Selection (if applicable) -->
<template if:true="{product.fields.HasVariants}">
<{{projectPrefixKebab}}-variant-selector
onvariantselect="{handleVariantSelect}"
></{{projectPrefixKebab}}-variant-selector>
</template>
<!-- Quantity -->
<div class="quantity-section">
<label for="qty">Quantity:</label>
<input
id="qty"
type="number"
value="{quantity}"
onchange="{handleQuantityChange}"
min="1"
/>
</div>
<!-- Add to Cart -->
<button class="add-to-cart-btn" onclick="{handleAddToCart}" disabled="{isOutOfStock}">
{isOutOfStock ? 'Out of Stock' : 'Add to Cart'}
</button>
<!-- Error Message -->
<template if:true="{errorMessage}">
<div class="error-message">{errorMessage}</div>
</template>
<!-- Product Description -->
<div class="description-section">
<h3>Description</h3>
<p>{product.fields.Description.value}</p>
</div>
</div>
</div>
</template>
</div>
</template>
Cart Line Item Component
// {{projectPrefixKebab}}-cart-line-item.js
import { LightningElement, api, track } from 'lwc';
export default class {{projectPrefixPascal}}CartLineItem extends LightningElement {
@api cartItem;
@api editable = true;
@track quantity;
connectedCallback() {
this.quantity = this.cartItem.quantity;
}
handleQuantityChange(event) {
this.quantity = parseInt(event.target.value, 10);
this.dispatchEvent(
new CustomEvent('quantitychange', {
detail: {
cartItemId: this.cartItem.id,
quantity: this.quantity,
},
}),
);
}
handleRemoveItem() {
this.dispatchEvent(
new CustomEvent('removeitem', {
detail: { cartItemId: this.cartItem.id },
}),
);
}
get subtotal() {
return this.cartItem.unitPrice * this.quantity;
}
get discountedPrice() {
if (this.cartItem.discount > 0) {
return this.cartItem.unitPrice - this.cartItem.discount;
}
return this.cartItem.unitPrice;
}
}
<!-- {{projectPrefixKebab}}-cart-line-item.html -->
<template>
<div class="cart-line-item">
<div class="item-image">
<img src="{cartItem.productImage}" alt="{cartItem.productName}" />
</div>
<div class="item-details">
<h4>{cartItem.productName}</h4>
<p class="sku">SKU: {cartItem.sku}</p>
<p class="price">${cartItem.unitPrice}</p>
</div>
<div class="item-quantity">
<label>Qty:</label>
<input
type="number"
value="{quantity}"
onchange="{handleQuantityChange}"
disabled="{!editable}"
min="1"
/>
</div>
<div class="item-total">
<p>${subtotal}</p>
</div>
<template if:true="{editable}">
<button class="remove-btn" onclick="{handleRemoveItem}">Remove</button>
</template>
</div>
</template>
Checkout Step Component
// {{projectPrefixKebab}}-checkout-step.js
import { LightningElement, api, track } from 'lwc';
export default class {{projectPrefixPascal}}CheckoutStep extends LightningElement {
@api stepNumber;
@api stepTitle;
@api isActive = false;
@api isCompleted = false;
@track formData = {};
@track errors = [];
handleInputChange(event) {
const { name, value } = event.target;
this.formData[name] = value;
}
async handleStepComplete() {
this.errors = [];
try {
// Validate form
const isValid = this.validate();
if (!isValid) {
return;
}
// Emit complete event
this.dispatchEvent(
new CustomEvent('stepcomplete', {
detail: {
stepNumber: this.stepNumber,
formData: this.formData,
},
bubbles: true,
composed: true,
}),
);
} catch (error) {
this.errors.push(error.message);
}
}
validate() {
// Step-specific validation logic
return true;
}
handleStepBack() {
this.dispatchEvent(
new CustomEvent('stepback', {
detail: { stepNumber: this.stepNumber },
bubbles: true,
composed: true,
}),
);
}
}
<!-- {{projectPrefixKebab}}-checkout-step.html -->
<template>
<div class="checkout-step">
<div class="step-header">
<span class="step-number">{stepNumber}</span>
<h3>{stepTitle}</h3>
</div>
<template if:true="{isActive}">
<div class="step-content">
<slot></slot>
</div>
<template if:true="{errors}">
<div class="errors">
<template for:each="{errors}" for:item="error">
<p key="{error}" class="error-message">{error}</p>
</template>
</div>
</template>
<div class="step-actions">
<button onclick="{handleStepBack}" class="btn-back">Back</button>
<button onclick="{handleStepComplete}" class="btn-next">Next</button>
</div>
</template>
<template if:false="{isActive}">
<div class="step-status">
<template if:true="{isCompleted}">
<span class="status-completed">Completed</span>
</template>
<template if:false="{isCompleted}">
<span class="status-pending">Pending</span>
</template>
</div>
</template>
</div>
</template>
My Account Section Component
// {{projectPrefixKebab}}-account-section.js
import { LightningElement, api, wire, track } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import getCurrentUser from '@salesforce/apex/AccountController.getCurrentUser';
import getAccountOrders from '@salesforce/apex/OrderController.getAccountOrders';
export default class {{projectPrefixPascal}}AccountSection extends LightningElement {
@api activeTab = 'profile';
@track userProfile;
@track orders;
@track addresses;
isLoading = false;
@wire(getCurrentUser)
wiredUser({ error, data }) {
if (data) {
this.userProfile = data;
} else if (error) {
console.error('Error loading user:', error);
}
}
@wire(getAccountOrders)
wiredOrders({ error, data }) {
if (data) {
this.orders = data;
}
}
handleTabChange(event) {
this.activeTab = event.detail.tab;
}
async handleProfileUpdate(event) {
this.isLoading = true;
try {
await updateUserProfile({ userData: event.detail });
this.dispatchEvent(new CustomEvent('profileupdated'));
} catch (error) {
console.error('Error updating profile:', error);
} finally {
this.isLoading = false;
}
}
}
<!-- {{projectPrefixKebab}}-account-section.html -->
<template>
<div class="account-section">
<{{projectPrefixKebab}}-tab-group onchangetab={handleTabChange}>
<{{projectPrefixKebab}}-tab label="Profile" name="profile">
<template if:true={activeTab.startsWith('profile')}>
<{{projectPrefixKebab}}-account-profile
user={userProfile}
onprofileupdate={handleProfileUpdate}
></{{projectPrefixKebab}}-account-profile>
</template>
</{{projectPrefixKebab}}-tab>
<{{projectPrefixKebab}}-tab label="Order History" name="orders">
<template if:true={activeTab.startsWith('orders')}>
<{{projectPrefixKebab}}-order-history orders={orders}></{{projectPrefixKebab}}-order-history>
</template>
</{{projectPrefixKebab}}-tab>
<{{projectPrefixKebab}}-tab label="Addresses" name="addresses">
<template if:true={activeTab.startsWith('addresses')}>
<{{projectPrefixKebab}}-address-list addresses={addresses}></{{projectPrefixKebab}}-address-list>
</template>
</{{projectPrefixKebab}}-tab>
</{{projectPrefixKebab}}-tab-group>
</div>
</template>
Search Filter Component
// {{projectPrefixKebab}}-search-filter.js
import { LightningElement, api, track, wire } from 'lwc';
import getFilterOptions from '@salesforce/apex/SearchController.getFilterOptions';
export default class {{projectPrefixPascal}}SearchFilter extends LightningElement {
@api searchQuery;
@track selectedFilters = {};
@track filterOptions;
@track isExpanded = false;
@wire(getFilterOptions, { searchQuery: '$searchQuery' })
wiredFilters({ error, data }) {
if (data) {
this.filterOptions = data;
}
}
handleFilterChange(event) {
const { filterName, value, checked } = event.detail;
if (!this.selectedFilters[filterName]) {
this.selectedFilters[filterName] = [];
}
if (checked) {
this.selectedFilters[filterName].push(value);
} else {
this.selectedFilters[filterName] = this.selectedFilters[filterName].filter(
(v) => v !== value,
);
}
this.dispatchEvent(
new CustomEvent('filterchange', {
detail: this.selectedFilters,
bubbles: true,
}),
);
}
handleClearFilters() {
this.selectedFilters = {};
this.dispatchEvent(
new CustomEvent('filterclear', {
bubbles: true,
}),
);
}
toggleExpand() {
this.isExpanded = !this.isExpanded;
}
}
<!-- {{projectPrefixKebab}}-search-filter.html -->
<template>
<div class="search-filter">
<button class="filter-toggle" onclick="{toggleExpand}">
Filters <span class="toggle-icon">{isExpanded ? '−' : '+'}</span>
</button>
<template if:true="{isExpanded}">
<div class="filter-panel">
<template for:each="{filterOptions}" for:item="filter">
<div key="{filter.name}" class="filter-group">
<h4>{filter.label}</h4>
<template for:each="{filter.options}" for:item="option">
<label key="{option.value}">
<input
type="checkbox"
onchange="{handleFilterChange}"
data-filter-name="{filter.name}"
data-value="{option.value}"
/>
{option.label} ({option.count})
</label>
</template>
</div>
</template>
<button class="btn-clear" onclick="{handleClearFilters}">Clear Filters</button>
</div>
</template>
</div>
</template>
Styling
CSS Custom Properties for Theming
Define Variables in Root
/* {{projectPrefixKebab}}-theme.css */
:root {
--color-primary: #0070d2;
--color-secondary: #005a9c;
--color-success: #04844b;
--color-error: #d0342d;
--color-warning: #ff9300;
--color-text: #333;
--color-border: #d8dce6;
--color-bg-light: #f7f8fc;
--font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-sm: 0.875rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-base: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--border-radius: 0.25rem;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12);
}
/* Use in components */
.button {
background-color: var(--color-primary);
color: white;
padding: var(--spacing-base) var(--spacing-lg);
border-radius: var(--border-radius);
font-family: var(--font-family-sans);
box-shadow: var(--shadow-sm);
}
.button:hover {
background-color: var(--color-secondary);
box-shadow: var(--shadow-md);
}
SLDS (Salesforce Lightning Design System) Usage
Standard Component Classes
<template>
<!-- Button styles -->
<button class="slds-button slds-button_brand">Primary Button</button>
<!-- Card component -->
<div class="slds-card">
<div class="slds-card__header slds-grid">
<header class="slds-media slds-media_center slds-has-flexi-truncate">
<div class="slds-media__body">
<h2 class="slds-card__title">Card Title</h2>
</div>
</header>
</div>
<div class="slds-card__body">Card content</div>
</div>
<!-- Form elements -->
<div class="slds-form-element">
<label class="slds-form-element__label">Label</label>
<div class="slds-form-element__control">
<input type="text" class="slds-input" />
</div>
</div>
<!-- Grid layout -->
<div class="slds-grid slds-wrap slds-grid_pull-padded">
<div class="slds-col slds-size_1-of-2 slds-p-horizontal_small">Item 1</div>
<div class="slds-col slds-size_1-of-2 slds-p-horizontal_small">Item 2</div>
</div>
</template>
Responsive Design Patterns
Mobile-First Approach
/* Mobile (default) */
.product-grid {
display: grid;
grid-template-columns: 1fr; /* 1 column on mobile */
gap: 1rem;
}
.sidebar {
display: none; /* Hide sidebar on mobile */
}
/* Tablet: 768px and up */
@media (min-width: 768px) {
.product-grid {
grid-template-columns: repeat(2, 1fr); /* 2 columns */
}
.sidebar {
display: block;
}
}
/* Desktop: 1024px and up */
@media (min-width: 1024px) {
.product-grid {
grid-template-columns: repeat(3, 1fr); /* 3 columns */
}
}
Accessibility
ARIA Labels and Roles
<template>
<!-- Button with aria-label for icon-only buttons -->
<button aria-label="Close modal" class="close-btn">
<svg class="icon"><!-- close icon --></svg>
</button>
<!-- Navigation with proper ARIA -->
<nav aria-label="Product Categories">
<ul class="category-list">
<li><a href="/category/dept-a">Department A</a></li>
<li><a href="/category/dept-b">Department B</a></li>
</ul>
</nav>
<!-- Form validation with aria-describedby -->
<div class="slds-form-element slds-has-error">
<label class="slds-form-element__label">Email</label>
<div class="slds-form-element__control">
<input type="email" aria-describedby="email-error" required />
</div>
<span id="email-error" class="slds-form-element__help"> Invalid email format </span>
</div>
<!-- Skip to main content link -->
<a href="#main-content" class="skip-link">Skip to main content</a>
</template>
Keyboard Navigation
// Ensure interactive elements are keyboard accessible
handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick();
}
}
// Modal trap focus within modal
connectedCallback() {
this.focusableElements = this.template.querySelectorAll(
'button, [href], input, [tabindex]:not([tabindex="-1"])'
);
this.firstElement = this.focusableElements[0];
this.lastElement = this.focusableElements[this.focusableElements.length - 1];
}
handleModalKeydown(event) {
if (event.key === 'Tab') {
if (event.shiftKey && document.activeElement === this.firstElement) {
event.preventDefault();
this.lastElement.focus();
} else if (!event.shiftKey && document.activeElement === this.lastElement) {
event.preventDefault();
this.firstElement.focus();
}
}
}
State Management
Wire Service for Commerce Data
Basic Wire Adapter
import { wire } from 'lwc';
import getProducts from '@salesforce/apex/ProductController.getProducts';
export default class {{projectPrefixPascal}}ProductListing extends LightningElement {
@wire(getProducts, { categoryId: '$categoryId' })
products;
get isLoading() {
return this.products.data === undefined && this.products.error === undefined;
}
get errorMessage() {
return this.products.error?.body?.message;
}
}
Cart State Management
Shared Cart Service
// {{projectPrefixKebab}}-cart-service.js
export class CartService {
constructor() {
this.cartId = null;
this.items = [];
this.listeners = [];
}
setCart(cartId) {
this.cartId = cartId;
this.notifyListeners();
}
addItem(item) {
this.items.push(item);
this.notifyListeners();
}
removeItem(itemId) {
this.items = this.items.filter((item) => item.id !== itemId);
this.notifyListeners();
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach((listener) => listener(this.getState()));
}
getState() {
return {
cartId: this.cartId,
items: this.items,
total: this.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
}
// Singleton instance
export const cartService = new CartService();
Using Cart Service in Components
import { LightningElement, track } from 'lwc';
import { cartService } from './{{projectPrefixKebab}}-cart-service';
export default class {{projectPrefixPascal}}CartIcon extends LightningElement {
@track cartTotal = 0;
connectedCallback() {
this.unsubscribe = cartService.subscribe((state) => {
this.cartTotal = state.items.length;
});
}
disconnectedCallback() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
}
NavigationMixin for Page Navigation
import { LightningElement, api } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
export default class {{projectPrefixPascal}}ProductCard extends NavigationMixin(LightningElement) {
@api product;
handleProductClick() {
// Navigate to product detail page
this[NavigationMixin.Navigate]({
type: 'standard__webPage',
attributes: {
url: `/product/${this.product.id}`,
},
});
}
handleCartClick() {
// Navigate to cart
this[NavigationMixin.Navigate]({
type: 'standard__webPage',
attributes: {
url: '/cart',
},
});
}
}
Lightning/uiRecordApi Patterns
import { LightningElement, api, wire } from 'lwc';
import { getRecord, updateRecord } from 'lightning/uiRecordApi';
import ID_FIELD from '@salesforce/schema/Account.Id';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import EMAIL_FIELD from '@salesforce/schema/Account.Email__c';
export default class {{projectPrefixPascal}}AccountEditor extends LightningElement {
@api recordId;
@track formData = {};
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, EMAIL_FIELD] })
account;
handleInputChange(event) {
const { name, value } = event.target;
this.formData = { ...this.formData, [name]: value };
}
async handleSave() {
try {
await updateRecord({
fields: {
Id: this.recordId,
Name: this.formData.name,
Email__c: this.formData.email,
},
});
this.dispatchEvent(new CustomEvent('recordupdated'));
} catch (error) {
console.error('Error updating record:', error);
}
}
}
Testing
Jest Test Patterns for LWC
Basic Component Test
// {{projectPrefixKebab}}-product-card.test.js
import { createElement } from 'lwc';
import {{projectPrefixPascal}}ProductCard from 'c/{{projectPrefixLower}}ProductCard';
describe('{{projectPrefixKebab}}-product-card', () => {
afterEach(() => {
// Clean up
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
});
it('renders product name', () => {
const element = createElement('{{projectPrefixKebab}}-product-card', {
is: {{projectPrefixPascal}}ProductCard,
});
element.product = {
id: '123',
name: 'Test Product',
price: 9.99,
};
document.body.appendChild(element);
const heading = element.shadowRoot.querySelector('h3');
expect(heading.textContent).toBe('Test Product');
});
it('dispatches addtocart event', () => {
const element = createElement('{{projectPrefixKebab}}-product-card', {
is: {{projectPrefixPascal}}ProductCard,
});
element.product = { id: '123', name: 'Product' };
document.body.appendChild(element);
const handler = jest.fn();
element.addEventListener('addtocart', handler);
const button = element.shadowRoot.querySelector('.add-btn');
button.click();
expect(handler).toHaveBeenCalled();
});
});
Mocking Wire Adapters
import { registerApexTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
import getProduct from '@salesforce/apex/ProductController.getProduct';
const getProductAdapter = registerApexTestWireAdapter(getProduct);
describe('{{projectPrefixKebab}}-product-detail', () => {
it('displays product from wire adapter', async () => {
const mockProduct = {
id: '123',
name: 'Test Product',
price: 9.99,
};
const element = createElement('{{projectPrefixKebab}}-product-detail', {
is: {{projectPrefixPascal}}ProductDetail,
});
element.productId = '123';
document.body.appendChild(element);
// Emit data from wire adapter
getProductAdapter.emit(mockProduct);
await element.updateComplete;
const heading = element.shadowRoot.querySelector('h1');
expect(heading.textContent).toContain('Test Product');
});
});
Mocking Apex Calls
import { createElement } from 'lwc';
import {{projectPrefixPascal}}Cart from 'c/{{projectPrefixLower}}Cart';
// Mock the Apex method
jest.mock(
'@salesforce/apex/CartController.addToCart',
() => ({
default: jest.fn(),
}),
{ virtual: true },
);
import addToCart from '@salesforce/apex/CartController.addToCart';
describe('{{projectPrefixKebab}}-cart', () => {
it('calls addToCart on button click', async () => {
addToCart.mockResolvedValue({ success: true });
const element = createElement('{{projectPrefixKebab}}-cart', {
is: {{projectPrefixPascal}}Cart,
});
document.body.appendChild(element);
const button = element.shadowRoot.querySelector('.add-btn');
button.click();
await element.updateComplete;
expect(addToCart).toHaveBeenCalled();
});
it('handles Apex error', async () => {
const error = new Error('Cart update failed');
addToCart.mockRejectedValue(error);
const element = createElement('{{projectPrefixKebab}}-cart', {
is: {{projectPrefixPascal}}Cart,
});
document.body.appendChild(element);
const button = element.shadowRoot.querySelector('.add-btn');
button.click();
await element.updateComplete;
const errorMsg = element.shadowRoot.querySelector('.error');
expect(errorMsg).toBeTruthy();
});
});
DOM Assertions
describe('DOM assertions', () => {
it('renders list items', () => {
const element = createElement('{{projectPrefixKebab}}-product-list', {
is: {{projectPrefixPascal}}ProductList,
});
element.products = [
{ id: '1', name: 'Product 1' },
{ id: '2', name: 'Product 2' },
];
document.body.appendChild(element);
const items = element.shadowRoot.querySelectorAll('.product-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Product 1');
});
it('shows/hides elements conditionally', () => {
const element = createElement('{{projectPrefixKebab}}-loading', {
is: {{projectPrefixPascal}}Loading,
});
element.isLoading = true;
document.body.appendChild(element);
let spinner = element.shadowRoot.querySelector('.spinner');
expect(spinner).toBeTruthy();
element.isLoading = false;
return element.updateComplete.then(() => {
spinner = element.shadowRoot.querySelector('.spinner');
expect(spinner).toBeFalsy();
});
});
});
Event Testing
describe('event testing', () => {
it('dispatches custom event with detail', () => {
const element = createElement('{{projectPrefixKebab}}-product-card', {
is: {{projectPrefixPascal}}ProductCard,
});
element.product = { id: '123' };
document.body.appendChild(element);
const handler = jest.fn();
element.addEventListener('quickview', handler);
const button = element.shadowRoot.querySelector('.quick-view-btn');
button.click();
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
detail: expect.objectContaining({
productId: '123',
}),
}),
);
});
});
Performance
Lazy Loading
// Lazy load images in product grid
export default class {{projectPrefixPascal}}ProductListing extends LightningElement {
renderedCallback() {
const images = this.template.querySelectorAll('img');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
imageObserver.unobserve(img);
}
});
});
images.forEach((img) => imageObserver.observe(img));
}
}
}
Virtual Scrolling for Large Lists
// Virtual scrolling for 200+ items
import { LightningElement, api, track } from 'lwc';
export default class {{projectPrefixPascal}}VirtualList extends LightningElement {
@api items;
@api itemHeight = 60;
@track visibleItems = [];
@track startIndex = 0;
connectedCallback() {
this.updateVisibleItems();
}
handleScroll(event) {
const container = event.target;
const scrollTop = container.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.updateVisibleItems();
}
updateVisibleItems() {
const visibleCount = Math.ceil(500 / this.itemHeight) + 2; // Buffer
this.visibleItems = this.items.slice(this.startIndex, this.startIndex + visibleCount);
}
get containerHeight() {
return 500; // px
}
get offsetY() {
return this.startIndex * this.itemHeight;
}
}
Image Optimization
<!-- Use responsive images and formats -->
<template>
<picture>
<source srcset="image.webp" type="image/webp" />
<source srcset="image.jpg" type="image/jpeg" />
<img src="image.jpg" alt="Product image" loading="lazy" width="300" height="300" />
</picture>
</template>
Bundle Size Awareness
Code Splitting
// Dynamic import for heavy components
async loadAdvancedFeatures() {
const { {{projectPrefixPascal}}AdvancedSearch } = await import(
/* webpackChunkName: "advanced" */
'./{{projectPrefixKebab}}-advanced-search'
);
// Use component
}
Common Pitfalls
Locker Service Limitations
Limitation: Cannot access window globals directly
// WRONG
const xhr = new XMLHttpRequest(); // Locker Service blocks direct access
// CORRECT
import { callApex } from '@salesforce/apex/Controller.method';
// Use Apex callout instead
Limitation: Cross-origin iframe restrictions
// Cannot embed arbitrary external iframes
<template>
<iframe src="https://external-site.com"></iframe>
</template>
// Use Salesforce-hosted content or visual force iframe
<lightning-iframe
src={iframeUrl}
title="External Content"
></lightning-iframe>
Shadow DOM Gotchas
Challenge: Styling from outside component doesn't affect shadow DOM
/* {{projectPrefixKebab}}-product-card.css - scoped to component */
:host {
/* Style the component root */
display: block;
padding: 1rem;
}
:host(.highlighted) {
/* Support external styling via class */
background-color: yellow;
}
Challenge: querySelector doesn't work across shadow boundaries
// Can't find element inside shadow DOM
document.querySelector('.product-card button');
// Use component method or expose via @api
@api getButton() {
return this.template.querySelector('button');
}
B2B Commerce API Quirks
Quirk: Product.StockKeepingUnit is read-only
// Cannot update SKU
update({ fields: { Id, StockKeepingUnit } });
// SKU is set at product creation
Quirk: CartCalculator runs on every cart change
// Must be performant and idempotent
handleCartCalculation() {
// Avoid expensive operations or side effects
// Called frequently during session
}
Pagination Patterns
// Implement offset-based pagination
export default class {{projectPrefixPascal}}SearchResults extends LightningElement {
@track results = [];
@track currentPage = 1;
@track pageSize = 20;
@track totalRecords = 0;
handlePageChange(event) {
this.currentPage = event.detail.page;
this.loadResults();
}
async loadResults() {
const offset = (this.currentPage - 1) * this.pageSize;
const response = await searchProducts({
query: this.searchQuery,
offset,
limit: this.pageSize,
});
this.results = response.records;
this.totalRecords = response.total;
}
get totalPages() {
return Math.ceil(this.totalRecords / this.pageSize);
}
}
Component Naming Convention
All components prefixed with {{projectPrefixKebab}}- for {{clientName}} branding:
{{projectPrefixKebab}}-product-card-- Product tile in listing{{projectPrefixKebab}}-product-detail-- Full product detail page{{projectPrefixKebab}}-cart-summary-- Cart overview{{projectPrefixKebab}}-cart-line-item-- Single cart item{{projectPrefixKebab}}-checkout-step-- Checkout flow step{{projectPrefixKebab}}-search-bar-- Search input{{projectPrefixKebab}}-search-filter-- Filter sidebar{{projectPrefixKebab}}-category-nav-- Category navigation{{projectPrefixKebab}}-account-section-- User account area{{projectPrefixKebab}}-order-history-- Order list{{projectPrefixKebab}}-payment-form-- Payment entry
Related Resources
See also:
- sfb2b-architect for system architecture decisions
- LWC Official Documentation: https://developer.salesforce.com/docs/component-library/overview/components
- SLDS: https://www.lightningdesignsystem.com/
- Jest Testing: https://jestjs.io/