Platform State Managers in LWC

In our previous blog post, we explored the fundamentals of @lwc/state by building a loan application using atoms, computed values, and actions. We learned how to create a global singleton/factory store that provides reactive state manager across multiple components and pages. Today, we’ll delve into how the Platform State Manager can enhance this management further and share the data across the application.

But what if you’re building a Salesforce-integrated application that needs to:

  • Retrieve record layouts dynamically from Salesforce
  • Load field values based on those layouts
  • Compose multiple data sources into a single state manager
  • Inject state managers into component hierarchies without prop drilling
  • Build reusable, configurable state managers

This is where the State Manager Pattern shines. In this advanced guide, we’ll explore how to build sophisticated state managers that integrate with Lightning Data Service, compose nested state managers, and leverage context-based dependency injection, taking full advantage of the Platform State Manager.

  • State Manager Functions: Reusable factories that create configured state manager instances
  • Nested State Managers: Composing multiple state managers together (state managers that use other state managers)
  • Lightning Data Service Integration: Using built-in state managers like `stateManagerRecord` and `stateManagerLayout`
  • Dynamic Configuration: State managers that adapt their behavior based on runtime configuration

The Problem: Loan Application Record Detail Panel

Let’s build a real-world Salesforce feature: a detail panel that displays record information. Get the full code at github repo

Requirements:

  1. Accept a recordId for existing loan applications
  2. Fetch the complete Loan_Application__c record with all fields
  3. Manage multi-step wizard flow (Personal → Employment → Financial → Review)
  4. Update record fields as user progresses through steps
  5. Calculate derived values (monthly payment, total assets, debt-to-income ratio)
  6. Handle loading states and validation

Why this is complex:

  • Record management: Need to fetch and update Salesforce records
  • Multi-step flow: Wizard state + record state coordination
  • Derived calculations: Business logic that depends on record data
  • Validation: Required fields and business rules
  • State synchronization: UI state + record state must stay in sync

Traditional approach problems:

  • Multiple @wire decorators with complex conditionals
  • Difficult to track loading states across multiple wires
  • Hard to test and reuse
  • Tight coupling between UI and data fetching logic

State Manager Pattern solution:

  • Single state manager orchestrates record fetching and wizard flow
  • Nested stateManagerRecord handles Salesforce record operations
  • Automatic reactivity between record data and UI
  • Clean separation of concerns
  • Highly testable and maintainable

Architecture: Loan Application State Manager

Here’s the structure of our loan application state manager:

┌─────────────────────────────────────────────────────────────┐
│          loanAppStateManagerRecord (Custom)                  │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ Wizard State:                                           │ │
│  │  • currentStep (1-4)                                   │ │
│  │                                                        │ │
│  │ Record Configuration:                                  │ │
│  │  • recordId (Loan_Application__c ID)                  │ │
│  │  • fields (all loan application fields)               │ │
│  │                                                        │ │
│  │ Nested State Manager:                                  │ │
│  │  ┌──────────────────────────────────────────────┐    │ │
│  │  │ applicationRecord = stateManagerRecord(...)  │    │ │
│  │  │   Manages Loan_Application__c record          │    │ │
│  │  │   Handles all CRUD operations                 │    │ │
│  │  └──────────────────────────────────────────────┘    │ │
│  │                                                        │ │
│  │ Computed Values:                                      │ │
│  │  • monthlyPayment (auto-calculated)                  │ │
│  │  • totalAssets (sum of all balances)                 │ │
│  │  • debtToIncomeRatio (financial ratio)               │ │
│  │  • isApplicationComplete (validation)                │ │
│  └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
                         ▲
                         │ (used by wizard)
                 ┌───────┴────────┐
                 │                │
          ┌──────────────┐ ┌──────────────┐
          │  Wizard      │ │  Summary     │
          │  Component   │ │  Component   │
          │  (Steps 1-4) │ │  (Dashboard) │
          └──────────────┘ └──────────────┘
       Both use same state manager instance
This is where the platform State Manager Pattern shines. In this advanced guide, we'll explore how to build sophisticated state managers that integrate with Lightning Data Service, compose nested state managers, and leverage context-based dependency injection.

Step 1: Understanding Built-in State Managers

Salesforce provides built-in state managers for common operations:

1. stateManagerRecord (from lightning/stateManagerRecord)

import stateManagerRecord from 'lightning/stateManagerRecord';

// Create a state manager for a record
const recordManager = stateManagerRecord({
    recordId: '001XXXXXXXXXXXX',
    fields: ['Account.Name', 'Account.Industry', 'Account.Phone']
});

// Access the data
recordManager.value.data;     // { id: '001...', fields: { Name: {...}, Industry: {...} } }
recordManager.value.error;    // Error object if something went wrong
recordManager.value.status;   // 'unconfigured' | 'loading' | 'loaded' | 'error'

Key features:

  • Fetches record data using UI API
  • Returns normalized record format
  • Handles loading states and errors
  • Can be reconfigured dynamically

2. stateManagerLayout (from lightning/stateManagerLayout)

import stateManagerLayout from 'lightning/stateManagerLayout';

// Create a state manager for a layout
const layoutManager = stateManagerLayout({
    objectApiName: 'Account',
    recordTypeId: '012XXXXXXXXXXXX',
    layoutType: 'Compact'  // or 'Full'
});

// Access the layout
layoutManager.value.data;     // Layout definition with sections, fields, etc.
layoutManager.value.error;    // Error object if something went wrong
layoutManager.value.status;   // 'unconfigured' | 'loading' | 'loaded' | 'error'

Key features:

  • Fetches layout metadata using UI API
  • Returns layout structure (sections, fields, components)
  • Supports different layout types
  • Can be reconfigured for different record types

Step 2: Building the Loan Application State Manager

Now let’s compose the built-in stateManagerRecord with our custom wizard logic:

Key Concepts Explained:

1. State Manager Factory Function

export default defineState(({ atom, computed, setAtom }) => {
    return (recordId, objectApiName) => {
        // This inner function is called each time you create an instance
        // Each instance has its own independent state
    };
});

Unlike the singleton pattern from Blog 1, this is a factory function. Each call creates a new, independent state manager instance.

2. Nested State Managers

const initialRecord = smRecord(computed([config], () => {
    return { recordId: '001...', fields: [...] };
}));

We pass a computed() value as the configuration for smRecord. This means:

  • When config changes, the computed value recalculates
  • The new config is passed to smRecord
  • smRecord fetches new data automatically

3. Data Waterfall

initialRecord ─(recordTypeId)─> layout ─(fields)─> finalRecord ─> data

Each step depends on the previous one. computed() handles all the dependencies automatically.

4. Status Aggregation

const status = computed([initialRecord, layout, finalRecord], (...statuses) => {
    // Combine statuses from all nested managers into single status
});

The overall status reflects the state of all nested managers.

loanAppStateManagerRecord.js

import { defineState } from '@lwc/state';
import smRecord from 'lightning/stateManagerRecord';

/**
 * Loan Application State Manager using lightning/stateManagerRecord
 * 
 * This state manager provides centralized state for the loan application wizard.
 * It uses lightning/stateManagerRecord as a nested state manager for managing
 * Salesforce Loan_Application__c records, and @lwc/state for wizard orchestration.
 * 
 * Implements singleton pattern - calling with different recordIds returns the same instance
 * and updates the recordId if needed.
 */

// Singleton instance storage
let stateInstance = null;
let currentRecordId = null;

export default defineState(({ atom, computed, setAtom }) => {
    return (recordId) => {
        // Return existing instance if already created
        if (stateInstance && recordId === currentRecordId) {
            return stateInstance;
        }
        
        // If recordId changed, reset the instance to create a new one
        if (stateInstance && recordId !== currentRecordId && recordId) {
            currentRecordId = recordId;
            stateInstance = null; // Reset to create new instance with new recordId
        }
        
        // Return existing instance even if no recordId passed (allows child components to access)
        if (stateInstance) {
            return stateInstance;
        }
        
        // Create new instance only if none exists
        currentRecordId = recordId;
        // Step tracking atom
        const currentStep = atom(1);
        
        // Record configuration atom
        const recordConfig = atom({
            recordId: recordId || undefined,
            fields: [
                // Personal Info fields
                'Loan_Application__c.First_Name__c',
                'Loan_Application__c.Last_Name__c',
                'Loan_Application__c.Email__c',
                'Loan_Application__c.Phone__c',
                'Loan_Application__c.SSN__c',
                'Loan_Application__c.Date_of_Birth__c',
                'Loan_Application__c.Street_Address__c',
                'Loan_Application__c.City__c',
                'Loan_Application__c.State__c',
                'Loan_Application__c.ZIP_Code__c',
                
                // Employment Info fields
                'Loan_Application__c.Employer_Name__c',
                'Loan_Application__c.Job_Title__c',
                'Loan_Application__c.Employment_Type__c',
                'Loan_Application__c.Monthly_Income__c',
                'Loan_Application__c.Years_Employed__c',
                'Loan_Application__c.Employer_Phone__c',
                
                // Financial Info fields
                'Loan_Application__c.Annual_Income__c',
                'Loan_Application__c.Other_Income__c',
                'Loan_Application__c.Monthly_Expenses__c',
                'Loan_Application__c.Credit_Score__c',
                'Loan_Application__c.Existing_Debts__c',
                'Loan_Application__c.Checking_Balance__c',
                'Loan_Application__c.Savings_Balance__c',
                'Loan_Application__c.Investments_Balance__c',
                'Loan_Application__c.Real_Estate_Value__c',
                
                // Loan Details fields
                'Loan_Application__c.Loan_Amount__c',
                'Loan_Application__c.Loan_Purpose__c',
                'Loan_Application__c.Loan_Term__c',
                'Loan_Application__c.Interest_Rate__c',
                
                // Status fields
                'Loan_Application__c.Status__c',
                'Loan_Application__c.CreatedDate',
                'Loan_Application__c.LastModifiedDate'
            ]
        });
        
        // Nested state manager for the Loan Application record
        // Pass the recordConfig atom directly - it will reactively update when recordId changes
        const applicationRecord = smRecord(recordConfig);

        // Computed value for monthly payment calculation
        const monthlyPayment = computed([applicationRecord], (appRec) => {
            if (!appRec || !appRec.data || !appRec.data.fields) return 0;
            
            const fields = appRec.data.fields;
            const principal = fields['Loan_Application__c.Loan_Amount__c']?.value || 0;
            const annualRate = (fields['Loan_Application__c.Interest_Rate__c']?.value || 0) / 100;
            const monthlyRate = annualRate / 12;
            const termMonths = (fields['Loan_Application__c.Loan_Term__c']?.value || 30) * 12;

            if (monthlyRate === 0) return principal / termMonths || 0;

            return principal * (monthlyRate * Math.pow(1 + monthlyRate, termMonths)) /
                   (Math.pow(1 + monthlyRate, termMonths) - 1) || 0;
        });

        // Computed value for total assets
        const totalAssets = computed([applicationRecord], (appRec) => {
            if (!appRec || !appRec.data || !appRec.data.fields) return 0;
            
            const fields = appRec.data.fields;
            const checking = fields['Loan_Application__c.Checking_Balance__c']?.value || 0;
            const savings = fields['Loan_Application__c.Savings_Balance__c']?.value || 0;
            const investments = fields['Loan_Application__c.Investments_Balance__c']?.value || 0;
            const realEstate = fields['Loan_Application__c.Real_Estate_Value__c']?.value || 0;
            
            return checking + savings + investments + realEstate;
        });

        // Computed value for debt-to-income ratio
        const debtToIncomeRatio = computed([applicationRecord], (appRec) => {
            if (!appRec || !appRec.data || !appRec.data.fields) return 0;
            
            const fields = appRec.data.fields;
            const monthlyIncome = fields['Loan_Application__c.Monthly_Income__c']?.value || 0;
            const monthlyDebts = (fields['Loan_Application__c.Existing_Debts__c']?.value || 0) / 12;
            
            return monthlyIncome > 0 ? (monthlyDebts / monthlyIncome) * 100 : 0;
        });

        // Computed value for application completion status
        const isApplicationComplete = computed([applicationRecord], (appRec) => {
            if (!appRec || !appRec.data || !appRec.data.fields) return false;
            
            const fields = appRec.data.fields;
            
            return !!(
                fields['Loan_Application__c.First_Name__c']?.value &&
                fields['Loan_Application__c.Last_Name__c']?.value &&
                fields['Loan_Application__c.Email__c']?.value &&
                fields['Loan_Application__c.Phone__c']?.value &&
                fields['Loan_Application__c.SSN__c']?.value &&
                fields['Loan_Application__c.Date_of_Birth__c']?.value &&
                fields['Loan_Application__c.Street_Address__c']?.value &&
                fields['Loan_Application__c.City__c']?.value &&
                fields['Loan_Application__c.State__c']?.value &&
                fields['Loan_Application__c.ZIP_Code__c']?.value &&
                fields['Loan_Application__c.Employer_Name__c']?.value &&
                fields['Loan_Application__c.Job_Title__c']?.value &&
                fields['Loan_Application__c.Monthly_Income__c']?.value > 0 &&
                fields['Loan_Application__c.Annual_Income__c']?.value > 0 &&
                fields['Loan_Application__c.Loan_Amount__c']?.value > 0 &&
                fields['Loan_Application__c.Loan_Purpose__c']?.value
            );
        });

        // Action to update current step
        const updateCurrentStep = (step) => {
            setAtom(currentStep, step);
        };

        // Action to update record fields
        const updateRecordFields = (fieldData) => {
            if (applicationRecord && applicationRecord.updateFields) {
                applicationRecord.updateFields(fieldData);
            }
        };

        // Store the instance
        stateInstance = {
            // State atoms/computed
            currentStep,
            applicationRecord,
            recordConfig,
            monthlyPayment,
            totalAssets,
            debtToIncomeRatio,
            isApplicationComplete,
            
            // Actions
            updateCurrentStep,
            updateRecordFields
        };

        // Return public API
        return stateInstance;
    };
});

Step 3: Using the State Manager in a Parent LWC

Now let’s use our state manager(loanAppStateManagerRecord) in a loan application wizard component:

loanWizardStateManager.js

import { LightningElement, api, wire } from 'lwc';
import loanAppStateManager from 'c/loanAppStateManagerRecord';
import { CurrentPageReference, NavigationMixin } from 'lightning/navigation';
/**
 * Loan Application Wizard using lightning/stateManagerRecord
 * 
 * This wizard manages a multi-step loan application with:
 * - 4 distinct steps (Personal, Employment, Financial, Review)
 * - Nested state management via lightning/stateManagerRecord
 * - Reactive updates for Salesforce record data
 * - Validation before step navigation
 */
export default class LoanWizardStateManager extends NavigationMixin(LightningElement) {
    @api recordId;
    objectApiName = 'Loan_Application__c';
    state;
    totalSteps = 4;
    isInitialized = false;
    connected = false;
    currentPageReference;
    
    // Wire the CurrentPageReference to track URL state changes
    @wire(CurrentPageReference)
    setCurrentPageReference(pageRef) {
        this.currentPageReference = pageRef;
        
        if (pageRef && pageRef.state) {
            // Get recordId and objectApiName from URL parameters
            const urlRecordId = pageRef.state.c__recordId;
            const urlObjectApiName = pageRef.state.c__objectApiName;
            
            console.log('URL Parameters:', { 
                recordId: urlRecordId, 
                objectApiName: urlObjectApiName 
            });
            
            if (urlRecordId) {
                this.recordId = urlRecordId;
            }
            if (urlObjectApiName) {
                this.objectApiName = urlObjectApiName;
            }
            
            // Initialize state manager if we have recordId and haven't initialized yet
            if (this.recordId && !this.isInitialized && this.connected) {
                console.log('Initializing state manager with recordId:', this.recordId);
                this.state = loanAppStateManager(this.recordId);
                console.log('State initialized:', this.state);
                this.isInitialized = true;
            }
        }
    }
    
    connectedCallback() {
        this.connected = true;
        console.log('Connected callback - recordId:', this.recordId);
        
        // Initialize state manager if recordId is already available
        if (this.recordId && !this.isInitialized) {
            console.log('Initializing state manager in connectedCallback');
            this.state = loanAppStateManager(this.recordId);
            console.log('State initialized connectedCallback:', this.state);
            this.isInitialized = true;
        } else if (!this.state && !this.isInitialized) {
            // Initialize with null if no recordId - this allows for new record creation
            console.log('Initializing state manager with null recordId');
            this.state = loanAppStateManager(null);
            this.isInitialized = true;
        }
    }

    get stateManagerRecord() {
        return this.state?.value;
    }

    get currentStep() {
        // Access currentStep directly as it's a primitive value in _value
        return this.stateManagerRecord?.currentStep || 1;
    }
    
    get hasRecordData() {
        // Access applicationRecord.data directly
        return !!this.stateManagerRecord?.applicationRecord?.data;
    }
    
    get recordStatus() {
        // Access applicationRecord.status directly
        return this.stateManagerRecord?.applicationRecord?.status || 'loading';
    }

    handleNext() {
        const currentStepValue = this.currentStep;
        // Determine next action based on current step
        const nextStep = currentStepValue + 1;
        this.stateManagerRecord.updateCurrentStep(nextStep); 
    }

    handlePrevious() {
        const currentStepValue = this.currentStep;
        if (currentStepValue > 1) {
            const previousStep = currentStepValue - 1;
            this.stateManagerRecord.updateCurrentStep(previousStep);
        }
    }
}

Use Platform State Manager In Child Component

import { LightningElement } from 'lwc';
import loanAppStateManager from 'c/loanAppStateManagerRecord';

export default class PersonalInfoStepSM extends LightningElement {
    state = loanAppStateManager(); // Access singleton instance
    
    get stateManagerRecord() {
        return this.state?.value; // Access the wrapped value
    }

    get applicationRecord() {
        // Access the nested lightning/stateManagerRecord
        return this.stateManagerRecord?.applicationRecord;
    }

    get fields() {
        // Get Salesforce record fields
        return this.applicationRecord?.data?.fields || {};
    }

    get firstName() {
        // Bind directly to Salesforce field
        return this.fields['First_Name__c']?.value || '';
    }

    // Handle input changes by updating Salesforce record
    handleInputChange(event) {
        const field = event.target.dataset.field;
        const value = event.target.value;
        
        // Update the Salesforce record through the nested state manager
        this.applicationRecord.updateFields({
            [`Loan_Application__c.${field}__c`]: value
        });
    }
}

Pass Data to Cross-Page Components using Platform State Manager

loanApplicationSummarySM.js

import { LightningElement } from 'lwc';
import loanAppStateManager from 'c/loanAppStateManagerRecord';

export default class LoanApplicationSummarySM extends LightningElement {
    state = loanAppStateManager(); // Same singleton instance
    
    get stateManagerRecord() {
        return this.state?.value;
    }

    get applicationRecord() {
        return this.stateManagerRecord?.applicationRecord;
    }

    get fields() {
        return this.applicationRecord?.data?.fields || {};
    }

    get applicantName() {
        const firstName = this.fields['First_Name__c']?.value || '';
        const lastName = this.fields['Last_Name__c']?.value || '';
        return firstName && lastName ? `${firstName} ${lastName}` : 'Not provided';
    }
}

When to Use Platform State Manager Pattern vs Basic @lwc/state

Use Basic @lwc/state Blog 1 When:

  • ✅ You need a simple global store
  • ✅ All components share the same data
  • ✅ Data doesn’t come from Salesforce APIs
  • ✅ You’re building a single-page app feature

Use State Manager Pattern (Blog 2) When:

  • ✅ You’re integrating with Salesforce APIs (records, layouts, etc.)
  • ✅ You need multiple independent instances (different records)
  • ✅ You have complex data dependencies (waterfalls)
  • ✅ You want to compose multiple data sources
  • ✅ You need context-based injection
  • ✅ You’re building reusable components for different contexts

Additional Resources

Note: @lwc/state is currently in Beta (Developer Preview). Features may change before general availability. Always test thoroughly in sandbox environments.

Do you need help?

Are you stuck while working on this requirement? Do you want to get review of your solution? Now, you can book dedicated 1:1 with me on Lightning Web Component and Agentforce completely free.

GET IN TOUCH

Schedule a 1:1 Meeting with me

One comment

Leave a Reply