← All insights

LWC State Managers: When Shared State Makes Sense

Several components watching the same selection is a coordination problem, not a query problem. This guide builds an Account–Contact workspace on Summer ’26 state managers, then draws the line between where a shared manager helps and where @api properties and events stay clearer.

Last updated: September 26, 2026

Related project · Masdar City Free Zone — Digital Experience & CRM Transformation

1. The problem: keeping multiple components in sync

An Account workspace is easy to understand. Choose an Account, see its Contacts, and select one Contact to inspect its details. The difficulty grows when different components need the same selection and must agree on what happens when it changes.

If a user switches from Account A to Account B, the previously selected Contact must clear. Otherwise, the screen can show Account B with a Contact from Account A. That is a state-coordination problem even when every database query is correct.

The example in this article uses standard Account and Contact records. The parent contains an Account picker and header. A Contact list and Contact detail panel are descendants. The parent also displays the selected Contact’s name, giving two parts of the UI a reason to observe the same selection.

2. What state managers add

For a small component tree, parent-owned values, @api properties, and custom events remain clear and reusable. State managers help when several descendants need the same reactive values and the same rules for changing them. Lightning Message Service solves cross-boundary communication; it does not decide who owns the current state.

Salesforce made LWC state managers generally available in Summer ’26. They require API version 67.0 and are currently supported in Lightning Experience, not Experience Cloud.

3. The core model

defineState() returns a factory, and each factory call creates an independent instance. atom() stores a reactive value, computed() derives a value from dependencies, and setAtom() updates an atom. Public values and actions are exposed through instance.value.

The provider creates one accountContactState instance. Descendants call fromContext(accountContactState) to resolve the nearest matching instance in their component ancestry. The provider must hold that reference when it connects and retain it; consumers must not read the context value from their constructors.

In this example, accountId and contactId are atoms. Contact rows and the selected Contact name are computed values. selectAccount() clears contactId before changing accountId, so every consumer observes the same invariant. The related-list manager receives a computed configuration and waits in an unconfigured state until an Account is selected.

4. Build the Account–Contact workspace

Architecture and scope

accountContactWorkspace owns one manager instance. accountContactList reads its Contact rows and calls selectContact(). accountContactDetail reads the same selected Contact ID. The custom manager composes lightning/stateManagerRelatedListRecords for the Account’s Contacts. The Account header and Contact detail use standard LDS record forms.

Architecture map

[ accountContactState ]

atoms: accountId, contactId

computed: status, error, rows, count, selectedName

actions: selectAccount(), selectContact(), refreshContacts()

one instance created by accountContactWorkspace

nearest instance resolved by descendants through fromContext()

UI components

[Account picker / workspace] → selectAccount(id)

[Contact list] ← accountId, rows → selectContact(id)

[Contact detail] ← contactId → LDS record form

This bounded learning example loads up to 50 Contacts, shows a loaded-record count, and supports record selection. It does not implement pagination, persistence, a global store, or multi-record transactions.

Prerequisites

Use a Summer ’26-or-later org with Lightning Experience and API version 67.0 for these bundles.

Use standard Account and Contact records. The running user needs access to the records and displayed fields; indirect Account Contact Relationships and Person Accounts are outside this example.

Create the four bundles

Create accountContactState, accountContactWorkspace, accountContactList, and accountContactDetail. The state module has a JavaScript file and metadata file only; it has no HTML template. The other three bundles each have JavaScript, HTML, and metadata files.

A. Shared state and related Contact data

accountContactState.js

import { defineState } from '@lwc/state';
import relatedListState from 'lightning/stateManagerRelatedListRecords';

function messageOf(error) {
    if (!error) return '';
    if (typeof error === 'string') return error;
    if (Array.isArray(error.body)) {
        return error.body.map(item => item.message).join('; ');
    }
    return error.body?.message || error.message || 'Unable to load contacts.';
}

export default defineState(({ atom, computed, setAtom }) => {
    const accountId = atom(undefined);
    const contactId = atom(undefined);

    const contacts = relatedListState(
        computed([accountId], id => id ? {
            parentRecordId: id,
            relatedListId: 'Contacts',
            fields: ['Contact.Id', 'Contact.Name'],
            optionalFields: ['Contact.Email'],
            pageSize: 50
        } : {})
    );

    const status = computed([contacts], value => value.status);
    const error = computed([contacts], value =>
        value.status === 'error' ? messageOf(value.error) : ''
    );
    const rows = computed([contacts], value => {
        if (value.status !== 'loaded') return [];
        return (value.data?.records || []).map(record => ({
            id: record.id,
            name: record.fields.Name?.value || 'Contact',
            email: record.fields.Email?.value || ''
        }));
    });
    const count = computed([rows], values => values.length);
    const selectedName = computed([rows, contactId], (values, id) =>
        values.find(row => row.id === id)?.name || 'None'
    );

    const selectAccount = id => {
        setAtom(contactId, undefined);
        setAtom(accountId, id || undefined);
    };
    const selectContact = id => {
        if (rows.value.some(row => row.id === id)) {
            setAtom(contactId, id);
        }
    };
    const refreshContacts = async () => {
        if (contacts.value.status === 'loaded') {
            await contacts.value.refresh();
        }
    };

    return {
        accountId, contactId, status, error, rows, count,
        selectedName, selectAccount, selectContact, refreshContacts
    };
});

B. Parent provider, Account picker, and header

accountContactWorkspace.js

import { LightningElement } from 'lwc';
import accountContactState from 'c/accountContactState';
import ACCOUNT_NAME from '@salesforce/schema/Account.Name';
import ACCOUNT_PHONE from '@salesforce/schema/Account.Phone';

export default class AccountContactWorkspace extends LightningElement {
    state = accountContactState();
    accountFields = [ACCOUNT_NAME, ACCOUNT_PHONE];

    get accountId() {
        return this.state.value.accountId;
    }

    get selectedName() {
        return this.state.value.selectedName;
    }

    handleAccountChange(event) {
        this.state.value.selectAccount(event.detail.recordId);
    }
}

accountContactWorkspace.html

<template>
    <lightning-card title="Account and Contact Workspace">
        <div class="slds-p-around_medium">
            <lightning-record-picker
                label="Choose an Account"
                object-api-name="Account"
                value={accountId}
                onchange={handleAccountChange}>
            </lightning-record-picker>

            <template lwc:if={accountId}>
                <lightning-record-form
                    record-id={accountId}
                    object-api-name="Account"
                    fields={accountFields}
                    mode="readonly">
                </lightning-record-form>
            </template>

            <p class="slds-m-top_small">
                Selected Contact: {selectedName}
            </p>

            <div class="slds-m-top_medium">
                <c-account-contact-list></c-account-contact-list>
            </div>
            <div class="slds-m-top_medium">
                <c-account-contact-detail></c-account-contact-detail>
            </div>
        </div>
    </lightning-card>
</template>

C. Contact list consumer

accountContactList.js

import { LightningElement } from 'lwc';
import { fromContext } from '@lwc/state';
import accountContactState from 'c/accountContactState';

export default class AccountContactList extends LightningElement {
    state = fromContext(accountContactState);

    get hasAccount() {
        return Boolean(this.state.value.accountId);
    }

    get loading() {
        return this.hasAccount &&
            ['unconfigured', 'loading'].includes(this.state.value.status);
    }

    get error() {
        return this.state.value.error;
    }

    get rows() {
        return this.state.value.rows;
    }

    get count() {
        return this.state.value.count;
    }

    get empty() {
        return this.state.value.status === 'loaded' && this.count === 0;
    }

    handleSelect(event) {
        this.state.value.selectContact(event.currentTarget.dataset.id);
    }
}

accountContactList.html

<template>
    <lightning-card title="Contacts">
        <div class="slds-p-around_medium">
            <template lwc:if={hasAccount}>
                <template lwc:if={loading}>
                    <p role="status">Loading contacts...</p>
                </template>
                <template lwc:elseif={error}>
                    <p role="alert">{error}</p>
                </template>
                <template lwc:else>
                    <p>Loaded contacts: {count} (maximum 50)</p>
                    <template lwc:if={empty}>
                        <p>No visible contacts for this Account.</p>
                    </template>
                    <ul class="slds-m-top_small">
                        <template for:each={rows} for:item="row">
                            <li key={row.id} class="slds-m-bottom_small">
                                <lightning-button
                                    label={row.name}
                                    data-id={row.id}
                                    onclick={handleSelect}>
                                </lightning-button>
                                <span class="slds-m-left_small">
                                    {row.email}
                                </span>
                            </li>
                        </template>
                    </ul>
                </template>
            </template>
            <template lwc:else>
                <p>Choose an Account to see its contacts.</p>
            </template>
        </div>
    </lightning-card>
</template>

D. Contact detail consumer

accountContactDetail.js

import { LightningElement } from 'lwc';
import { fromContext } from '@lwc/state';
import accountContactState from 'c/accountContactState';
import FIRST_NAME from '@salesforce/schema/Contact.FirstName';
import LAST_NAME from '@salesforce/schema/Contact.LastName';
import EMAIL from '@salesforce/schema/Contact.Email';
import PHONE from '@salesforce/schema/Contact.Phone';

export default class AccountContactDetail extends LightningElement {
    state = fromContext(accountContactState);
    fields = [FIRST_NAME, LAST_NAME, EMAIL, PHONE];

    get contactId() {
        return this.state.value.contactId;
    }
}

accountContactDetail.html

<template>
    <lightning-card title="Contact Details">
        <div class="slds-p-around_medium">
            <template lwc:if={contactId}>
                <lightning-record-form
                    record-id={contactId}
                    object-api-name="Contact"
                    fields={fields}
                    mode="readonly">
                </lightning-record-form>
            </template>
            <template lwc:else>
                <p>Select a Contact from the list.</p>
            </template>
        </div>
    </lightning-card>
</template>

The related-list configuration is itself computed. With no Account selected, it returns an empty configuration so the nested manager waits, following Salesforce’s composition example. With an Account selected, it requests the Contacts child relationship. The UI reads record data only after status is loaded.

Email is an optional field: inaccessible Email data should not fail the entire list request. A blank Email cell may mean no stored value or no access. Do not treat those cases as equivalent in a business rule. The detail form applies its own LDS access handling.

The Account picker emits the selected ID in event.detail.recordId. On clearing it, the value is null; the action normalizes this to undefined and clears the Contact.

Deploy and verify

Deploy the four bundles, add accountContactWorkspace to an App Page, and activate it. Only the parent should appear in App Builder; the state module and two consumers remain unexposed children.

Verify the flow in this order:

  • No Account selected: the list and detail panel show their initial prompts.

  • Select an Account: its fields and direct Contacts load.

  • Select a Contact: the detail panel and parent summary show the same selection.

  • Switch Accounts: the previous Contact clears before the new list becomes active.

  • Clear the Account, test an Account with no Contacts, reload the page, and place two workspace instances on one page to confirm empty, non-persistent, and instance-isolated state.

5. Keep UI state, Salesforce data, and drafts separate

Selected Account and Contact IDs are UI state. Account and Contact records are server-backed data. Unsaved form values are draft state. Changing an atom does not write a Salesforce record.

Built-in state managers use LDS and UI API, so permissions, field access, sharing, caching, and supported refresh behaviour still apply. Wire adapters require component context and cannot run inside a state manager. Base record forms remain a good fit for standard display, validation, and simple editing.

If an imperative Apex call changes records, call notifyRecordUpdateAvailable([{ recordId }]) for affected LDS records. Use refreshApex() for data provisioned by an Apex wire. List membership can still require its own refresh strategy.

Production failure modes

  • Unsaved drafts when selection changes: If a user edits a Contact and then selects another Account or Contact, selectAccount() or selectContact() can replace the active record before the user saves. This read-only example has no draft, but an editable version must block the selection change or confirm that the draft can be discarded.

  • Duplicated Salesforce data: Avoid copying full LDS record payloads into separate atoms merely to create another cache. This example intentionally composes lightning/stateManagerRelatedListRecords inside the manager and exposes derived rows because several consumers share the list. For standalone record data, a base record component or @wire is usually simpler.

6. Choose the simplest pattern that fits

  • Use local component fields for state owned by one component.

  • Use @api properties and custom events for a small parent–child contract.

  • Use a state manager when several descendants need the same changing values and update rules.

  • Use Lightning Message Service for notifications across supported DOM boundaries, with state ownership defined separately.

  • Compose built-in state managers when a coordinated page needs several dependent Salesforce data sources.

This workspace could be built with parent-owned properties and events. The state-manager version becomes valuable when consumers, nesting, and shared rules grow—not merely because several components exist.

When not to use a state manager

  • Shallow parent–child flow: If values move only one or two levels through a linear hierarchy, @api properties and custom events are usually clearer.

  • Private transient state: A dropdown’s open state, a component-local spinner, and other state that no sibling consumes should remain local to that component.

  • Persistence across navigation: A context-provided manager follows the provider’s DOM lifetime. Do not treat it as durable session storage across hard navigation or unrelated App Builder pages. If persistence is required, design it explicitly and review security before using browser storage.

7. Boundaries that matter

  • Experience Cloud is currently excluded by Salesforce’s state-management documentation.

  • fromContext() follows component ancestry; separately placed App Builder components do not share a provider.

  • Retain the provider’s manager reference instead of recreating it when filters change.

  • The demo loads at most 50 direct Contacts. Its count is the number loaded, not the Account’s total Contact count.

  • A manager instance is neither persistent storage nor cross-browser synchronization.

  • If you add custom searches or Apex, protect against stale responses and define explicit retry, offline, and conflict behaviour.

  • Keep managers cohesive. A page-level state layer is useful; one store for every page and transient flag creates coupling.

8. The architect’s decision

Default to @api properties and custom events while the component contract is short and explicit. Introduce a state manager when sibling or deeply nested consumers need the same changing values and the same update rules.

Use the manager as the page-level coordination layer: own shared IDs, flags, derived values, and actions there; let LDS, UI API, base record components, or composed built-in state managers own Salesforce data retrieval and caching. Centralizing transitions such as “changing the Account clears the selected Contact” reduces inconsistent UI states, but production code must still handle unsaved drafts, stale asynchronous responses, refresh failures, and navigation lifetime explicitly.

9. Sources and references