mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
20 KiB
20 KiB
LWC State Management
Modern state management patterns for Lightning Web Components using @lwc/state and Platform State Managers
Overview
LWC state management has evolved beyond simple reactive properties. This guide covers modern patterns for managing complex state across components, including the @lwc/state library and Salesforce Platform State Managers.
┌─────────────────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT SPECTRUM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SIMPLE ◄────────────────────────────────────────────────────────► COMPLEX │
│ │
│ @track/@api Singleton Store @lwc/state Platform State │
│ (Component) (Cross-Component) (Full Library) (Record/Layout) │
│ │
│ ┌─────────┐ ┌─────────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ Single │ │ Shared │ │ Atoms + │ │ Record Data + │ │
│ │ Component│ │ Across │ │ Computed + │ │ Layout State │ │
│ │ State │ │ Components │ │ Actions │ │ (Platform) │ │
│ └─────────┘ └─────────────┘ └────────────┘ └───────────────┘ │
│ │
│ Use when: Use when: Use when: Use when: │
│ - Local state - Shared cart - Complex UI - Record pages │
│ - Form fields - User prefs - Async state - Flexipages │
│ - UI toggles - Cached data - Derived data - Tab persistence │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
When to Use Each Pattern
| Pattern | Complexity | Scope | Use Case |
|---|---|---|---|
| @track / reactive properties | Low | Component | Form inputs, toggles, local UI state |
| Singleton Store | Medium | Cross-component | Shopping cart, filters, user preferences |
| @lwc/state | Medium-High | Cross-component | Complex forms, async state, computed values |
| Platform State Managers | High | Page/Record | Record pages, layout-aware components |
Pattern 1: Reactive Properties (Component-Level)
Standard Reactivity
import { LightningElement } from 'lwc';
export default class SimpleState extends LightningElement {
// Reactive by default (primitive types and objects)
counter = 0;
isActive = false;
user = { name: 'John', email: 'john@example.com' };
// Object reassignment triggers reactivity
updateUser() {
// ✅ Works - new object reference
this.user = { ...this.user, name: 'Jane' };
// ❌ Won't trigger rerender - same reference
// this.user.name = 'Jane';
}
increment() {
this.counter++; // ✅ Primitive assignment is reactive
}
}
Getters (Computed Properties)
export default class ComputedExample extends LightningElement {
firstName = '';
lastName = '';
items = [];
// Computed property - recalculates when dependencies change
get fullName() {
return `${this.firstName} ${this.lastName}`.trim();
}
get hasItems() {
return this.items.length > 0;
}
get totalPrice() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
get formattedPrice() {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(this.totalPrice);
}
}
Pattern 2: Singleton Store (Cross-Component State)
For sharing state across components without platform dependencies.
Store Module (store.js)
/**
* Singleton store for cross-component state management.
*
* Usage:
* import store from 'c/store';
*
* // Read state
* const cart = store.getState('cart');
*
* // Update state
* store.setState('cart', { items: [...cart.items, newItem] });
*
* // Subscribe to changes
* store.subscribe('cart', (newCart) => { this.cart = newCart; });
*/
// Private state container
const state = new Map();
// Subscribers by key
const subscribers = new Map();
// Get current state for a key
function getState(key) {
return state.get(key);
}
// Set state and notify subscribers
function setState(key, value) {
const oldValue = state.get(key);
state.set(key, value);
// Notify all subscribers for this key
const keySubscribers = subscribers.get(key) || [];
keySubscribers.forEach(callback => {
try {
callback(value, oldValue);
} catch (e) {
console.error('Store subscriber error:', e);
}
});
}
// Subscribe to state changes
function subscribe(key, callback) {
if (!subscribers.has(key)) {
subscribers.set(key, []);
}
subscribers.get(key).push(callback);
// Return unsubscribe function
return () => {
const keySubscribers = subscribers.get(key) || [];
const index = keySubscribers.indexOf(callback);
if (index > -1) {
keySubscribers.splice(index, 1);
}
};
}
// Initialize state with default values
function initState(key, defaultValue) {
if (!state.has(key)) {
state.set(key, defaultValue);
}
return state.get(key);
}
// Clear state (useful for testing)
function clearState() {
state.clear();
subscribers.clear();
}
export default {
getState,
setState,
subscribe,
initState,
clearState
};
Using the Store
// cartManager.js
import { LightningElement } from 'lwc';
import store from 'c/store';
export default class CartManager extends LightningElement {
cart = { items: [], total: 0 };
unsubscribe;
connectedCallback() {
// Initialize cart state
this.cart = store.initState('cart', { items: [], total: 0 });
// Subscribe to cart changes from other components
this.unsubscribe = store.subscribe('cart', (newCart) => {
this.cart = newCart;
});
}
disconnectedCallback() {
// Clean up subscription
if (this.unsubscribe) {
this.unsubscribe();
}
}
addItem(event) {
const item = event.detail;
const currentCart = store.getState('cart');
const newCart = {
items: [...currentCart.items, item],
total: currentCart.total + item.price
};
store.setState('cart', newCart);
}
}
Pattern 3: @lwc/state Library
The @lwc/state library provides reactive state primitives with automatic dependency tracking.
Installation
# If using npm in LWC project
npm install @lwc/state
Core Concepts
Atoms (Primitive State)
import { atom, computed } from '@lwc/state';
// Create atoms for primitive state
const countAtom = atom(0);
const nameAtom = atom('');
const itemsAtom = atom([]);
// Read value
console.log(countAtom.value); // 0
// Write value - triggers reactivity
countAtom.value = 5;
// Reset to initial value
countAtom.reset();
Computed (Derived State)
import { atom, computed } from '@lwc/state';
const priceAtom = atom(100);
const quantityAtom = atom(2);
const taxRateAtom = atom(0.08);
// Computed automatically recalculates when dependencies change
const subtotal = computed(() => priceAtom.value * quantityAtom.value);
const tax = computed(() => subtotal.value * taxRateAtom.value);
const total = computed(() => subtotal.value + tax.value);
console.log(total.value); // 216 (100 * 2 * 1.08)
// Update a dependency - all computed values update
priceAtom.value = 150;
console.log(total.value); // 324 (150 * 2 * 1.08)
Actions (State Mutations)
import { atom, action } from '@lwc/state';
const cartAtom = atom({ items: [], total: 0 });
// Actions encapsulate state mutations
const addToCart = action((item) => {
const cart = cartAtom.value;
cartAtom.value = {
items: [...cart.items, item],
total: cart.total + item.price
};
});
const removeFromCart = action((itemId) => {
const cart = cartAtom.value;
const item = cart.items.find(i => i.id === itemId);
cartAtom.value = {
items: cart.items.filter(i => i.id !== itemId),
total: cart.total - (item?.price || 0)
};
});
const clearCart = action(() => {
cartAtom.reset();
});
LWC Integration
import { LightningElement } from 'lwc';
import { atom, computed } from '@lwc/state';
// Define atoms outside component (singleton)
const searchTermAtom = atom('');
const resultsAtom = atom([]);
const isLoadingAtom = atom(false);
// Computed values
const hasResults = computed(() => resultsAtom.value.length > 0);
const resultCount = computed(() => resultsAtom.value.length);
export default class SearchComponent extends LightningElement {
// Bind atoms to component for reactivity
get searchTerm() {
return searchTermAtom.value;
}
get results() {
return resultsAtom.value;
}
get isLoading() {
return isLoadingAtom.value;
}
get hasResults() {
return hasResults.value;
}
handleSearchChange(event) {
searchTermAtom.value = event.target.value;
this.performSearch();
}
async performSearch() {
if (searchTermAtom.value.length < 2) {
resultsAtom.value = [];
return;
}
isLoadingAtom.value = true;
try {
const results = await searchRecords({ term: searchTermAtom.value });
resultsAtom.value = results;
} finally {
isLoadingAtom.value = false;
}
}
}
Pattern 4: Platform State Managers
Salesforce provides built-in state managers for record pages and layouts.
stateManagerRecord
Manages record data with automatic refresh and caching.
import { LightningElement, wire } from 'lwc';
import { stateManagerRecord } from 'lightning/stateManagerRecord';
import { getRecord } from 'lightning/uiRecordApi';
export default class RecordStateExample extends LightningElement {
@api recordId;
// Wire with state manager for enhanced caching
@wire(stateManagerRecord, {
recordId: '$recordId',
fields: ['Account.Name', 'Account.Industry']
})
recordState;
get accountName() {
return this.recordState?.data?.fields?.Name?.value;
}
get industry() {
return this.recordState?.data?.fields?.Industry?.value;
}
// State manager provides loading/error states
get isLoading() {
return this.recordState?.loading;
}
get hasError() {
return !!this.recordState?.error;
}
}
stateManagerLayout
Manages layout-aware state for flexipage components.
import { LightningElement, wire } from 'lwc';
import { stateManagerLayout } from 'lightning/stateManagerLayout';
export default class LayoutAwareComponent extends LightningElement {
@api recordId;
@api objectApiName;
// Wire layout state manager
@wire(stateManagerLayout, {
recordId: '$recordId',
objectApiName: '$objectApiName'
})
layoutState;
get isCompact() {
return this.layoutState?.density === 'compact';
}
get visibleFields() {
return this.layoutState?.fields || [];
}
}
Composing State Managers
import { LightningElement, wire } from 'lwc';
import { stateManagerRecord } from 'lightning/stateManagerRecord';
import { atom, computed } from '@lwc/state';
// Custom state atoms
const selectedTabAtom = atom('details');
const expandedSectionsAtom = atom(new Set(['overview']));
export default class ComposedStateComponent extends LightningElement {
@api recordId;
// Platform state for record data
@wire(stateManagerRecord, {
recordId: '$recordId',
fields: ['Account.Name', 'Account.Type', 'Account.Industry']
})
recordState;
// Custom state for UI
get selectedTab() {
return selectedTabAtom.value;
}
get expandedSections() {
return expandedSectionsAtom.value;
}
isSectionExpanded(sectionId) {
return expandedSectionsAtom.value.has(sectionId);
}
handleTabChange(event) {
selectedTabAtom.value = event.detail.value;
}
handleSectionToggle(event) {
const sectionId = event.target.dataset.section;
const sections = new Set(expandedSectionsAtom.value);
if (sections.has(sectionId)) {
sections.delete(sectionId);
} else {
sections.add(sectionId);
}
expandedSectionsAtom.value = sections;
}
}
Anti-Patterns to Avoid
BAD: Mutating Objects In Place
// DON'T - won't trigger reactivity
this.user.name = 'New Name';
this.items.push(newItem);
GOOD: Create New References
// DO - triggers reactivity
this.user = { ...this.user, name: 'New Name' };
this.items = [...this.items, newItem];
BAD: Heavy Computation in Getters
// DON'T - runs every render cycle
get expensiveComputation() {
return this.items
.map(item => complexTransform(item))
.filter(item => complexFilter(item))
.sort((a, b) => complexSort(a, b));
}
GOOD: Cache Computed Values
_cachedResult;
_lastItemsHash;
get optimizedComputation() {
const currentHash = JSON.stringify(this.items);
if (this._lastItemsHash !== currentHash) {
this._cachedResult = this.items
.map(item => complexTransform(item))
.filter(item => complexFilter(item))
.sort((a, b) => complexSort(a, b));
this._lastItemsHash = currentHash;
}
return this._cachedResult;
}
BAD: Forgetting to Unsubscribe
// DON'T - memory leak
connectedCallback() {
store.subscribe('data', (data) => this.data = data);
}
GOOD: Clean Up Subscriptions
// DO - proper cleanup
_unsubscribe;
connectedCallback() {
this._unsubscribe = store.subscribe('data', (data) => this.data = data);
}
disconnectedCallback() {
if (this._unsubscribe) {
this._unsubscribe();
}
}
Best Practices Summary
┌─────────────────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT BEST PRACTICES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CHOOSE THE RIGHT PATTERN │
│ ───────────────────────────────────────────────────────────────────────── │
│ ✅ Start with simple reactive properties │
│ ✅ Use singleton store for shared non-record state │
│ ✅ Use @lwc/state for complex derived state │
│ ✅ Use Platform State Managers on record pages │
│ ❌ Don't over-engineer simple components │
│ │
│ REACTIVITY │
│ ───────────────────────────────────────────────────────────────────────── │
│ ✅ Create new object/array references for updates │
│ ✅ Use spread operator: { ...obj, newProp } │
│ ✅ Use getters for computed values │
│ ❌ Don't mutate objects/arrays in place │
│ │
│ PERFORMANCE │
│ ───────────────────────────────────────────────────────────────────────── │
│ ✅ Cache expensive computations │
│ ✅ Debounce rapid state updates │
│ ✅ Clean up subscriptions in disconnectedCallback │
│ ❌ Don't do heavy computation in getters │
│ │
│ TESTING │
│ ───────────────────────────────────────────────────────────────────────── │
│ ✅ Test state transitions explicitly │
│ ✅ Verify subscription cleanup │
│ ✅ Mock store for unit tests │
│ ❌ Don't test implementation details │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Related Documentation
- SKILL.md - PICKLES Framework overview
- lwc-best-practices.md - General LWC patterns
- triangle-pattern.md - Component composition
Source
References:
- Mastering State Management in LWC using @lwc/state - Salesforce Diaries
- Platform State Managers in LWC - Salesforce Diaries