Log Entry
State Managers for LWC: Be Excited, Keep Your Components Raw
I am properly excited about LWC state managers.
Not "new framework smell" excited.
Actually excited.
State managers give LWC a native way to move data, derived values, and actions out of individual components and into a dedicated state layer. That is exactly the thing a lot of serious LWC apps have been missing.
But there is a trap.
If every component imports the state manager directly, you have not decoupled the UI from the data model. You have just moved the coupling somewhere trendier.
The best version of this pattern is:
- state managers own data and actions
- container and adaptor components connect state to the page
- raw UI components receive properties and emit events
- simple components stay simple
That gives you the upside without making every button, row, badge, and panel depend on one state shape forever.
Quick snapshot
- What: LWC state managers centralise related state, computed values, and actions.
- Core API:
defineState(),atom(),computed(),setAtom(), andfromContext(). - Built-in managers: Salesforce provides
lightning/stateManager*modules for common LDS and UI API data. - Useful for: complex pages, shared state, coordinated components, and derived values.
- Still use props/events for: small parent-child relationships and raw reusable UI components.
- Caveat: Salesforce says state management is not available in Experience Cloud at this time.
My short version:
Put business state in the state manager. Keep your UI components portable.
The old LWC shape
The classic LWC pattern works well until the page gets busy.
You start with one parent component fetching data and passing it down.
Then you add filters.
Then selection.
Then a summary panel.
Then row actions.
Then a footer button that needs to know what the list selected.
Suddenly your component tree looks like this.
flowchart TD A["c-alarm-console - Apex calls, filters, selection, loading, save logic"] B["c-alarm-toolbar - filter props"] C["c-severity-filter - selected severity"] D["c-alarm-list - rows and selected IDs"] E["c-alarm-row - row props"] F["c-alarm-summary - derived counts"] G["c-alarm-footer - bulk action state"] A -->|props| B B -->|props| C C -->|filterchange event| B B -->|filterchange event| A A -->|props| D D -->|props| E E -->|select event| D D -->|select event| A A -->|props| F A -->|props| G G -->|acknowledge event| A
This is not wrong.
For small pages, it is still the right answer.
The problem is that the parent becomes a traffic controller. It owns too many things:
- the Apex call
- loading state
- error state
- filter state
- selected row IDs
- derived counts
- actions
- event translation
- child component wiring
That makes the parent hard to test and easy to break.
The new shape
With a state manager, the page can move shared state and actions into one dedicated module.
flowchart TD S["alarmWorkspaceState - atoms, computed values, actions"] P["c-alarm-workspace - creates state manager instance"] T["c-alarm-toolbar-adapter - fromContext"] L["c-alarm-list-adapter - fromContext"] M["c-alarm-summary-adapter - fromContext"] F["c-alarm-footer-adapter - fromContext"] RT["c-alarm-toolbar - raw props and events"] RL["c-alarm-list - raw props and events"] RM["c-alarm-summary - raw props"] RF["c-alarm-footer - raw props and events"] P --> S S --> T S --> L S --> M S --> F T --> RT L --> RL M --> RM F --> RF
That is the architecture I want.
Not "every component talks to the store".
"The stateful shell talks to the store, and the raw UI remains raw."
Example 1: a state manager for an alarm workspace
This is the kind of logic I want out of the component tree.
// alarmWorkspaceState.js
import { defineState } from '@lwc/state';
import getAlarmSummaries from '@salesforce/apex/AlarmConsoleController.getAlarmSummaries';
import acknowledgeAlarms from '@salesforce/apex/AlarmConsoleController.acknowledgeAlarms';
export default defineState(({ atom, computed, setAtom }) => {
const rows = atom([]);
const filters = atom({
severity: 'All',
searchTerm: ''
});
const selectedIds = atom([]);
const isLoading = atom(false);
const error = atom(undefined);
const visibleRows = computed([rows, filters], () => {
const currentFilters = filters.value;
const searchTerm = currentFilters.searchTerm.trim().toLowerCase();
return rows.value.filter((row) => {
const matchesSeverity =
currentFilters.severity === 'All' || row.severity === currentFilters.severity;
const matchesSearch =
!searchTerm || row.name.toLowerCase().includes(searchTerm);
return matchesSeverity && matchesSearch;
});
});
const selectedCount = computed([selectedIds], () => selectedIds.value.length);
const load = async () => {
setAtom(isLoading, true);
setAtom(error, undefined);
try {
setAtom(rows, await getAlarmSummaries());
} catch (loadError) {
setAtom(error, loadError);
} finally {
setAtom(isLoading, false);
}
};
const setSeverity = (severity) => {
setAtom(filters, {
...filters.value,
severity
});
};
const setSearchTerm = (searchTerm) => {
setAtom(filters, {
...filters.value,
searchTerm
});
};
const toggleSelected = (alarmId) => {
const nextIds = new Set(selectedIds.value);
if (nextIds.has(alarmId)) {
nextIds.delete(alarmId);
} else {
nextIds.add(alarmId);
}
setAtom(selectedIds, [...nextIds]);
};
const acknowledgeSelected = async () => {
const alarmIds = selectedIds.value;
if (!alarmIds.length) {
return;
}
await acknowledgeAlarms({ alarmIds });
setAtom(rows, rows.value.filter((row) => !alarmIds.includes(row.id)));
setAtom(selectedIds, []);
};
return {
error,
filters,
isLoading,
load,
rows,
selectedCount,
selectedIds,
setSearchTerm,
setSeverity,
toggleSelected,
acknowledgeSelected,
visibleRows
};
});
That is a good home for this logic.
The state manager owns:
- what records are loaded
- which filters apply
- which rows are selected
- which values are derived
- which actions are allowed
The component tree does not need to pass all of that through every layer.
Example 2: the provider component
The page-level component creates the state manager instance.
// alarmWorkspace.js
import { LightningElement } from 'lwc';
import alarmWorkspaceState from 'c/alarmWorkspaceState';
export default class AlarmWorkspace extends LightningElement {
state = alarmWorkspaceState();
connectedCallback() {
this.state.value.load();
}
}
The template composes the page.
<!-- alarmWorkspace.html -->
<template>
<c-alarm-toolbar-adapter></c-alarm-toolbar-adapter>
<c-alarm-summary-adapter></c-alarm-summary-adapter>
<c-alarm-list-adapter></c-alarm-list-adapter>
<c-alarm-footer-adapter></c-alarm-footer-adapter>
</template>
Salesforce's context model matters here. The provider creates or owns the state manager instance, and descendants retrieve the closest matching instance with fromContext().
That gives you scoped state.
Two alarm workspaces on the same page can have two separate state manager instances.
Example 3: an adaptor component
This is where I like to put the state-manager dependency.
// alarmListAdapter.js
import { LightningElement } from 'lwc';
import { fromContext } from '@lwc/state';
import alarmWorkspaceState from 'c/alarmWorkspaceState';
export default class AlarmListAdapter extends LightningElement {
state = fromContext(alarmWorkspaceState);
get rows() {
const selectedIds = new Set(this.state?.value.selectedIds ?? []);
return (this.state?.value.visibleRows ?? []).map((row) => ({
...row,
selectLabel: selectedIds.has(row.id) ? 'Selected' : 'Select'
}));
}
handleToggleSelected(event) {
this.state.value.toggleSelected(event.detail.alarmId);
}
}
<!-- alarmListAdapter.html -->
<template>
<c-alarm-list
rows={rows}
ontoggleselected={handleToggleSelected}>
</c-alarm-list>
</template>
The adaptor knows about the state manager.
The raw list does not.
That boundary is the difference between a flexible architecture and a page-specific knot.
Example 4: the raw component stays reusable
The list component should still feel like normal LWC.
// alarmList.js
import { LightningElement, api } from 'lwc';
export default class AlarmList extends LightningElement {
@api rows = [];
handleToggleSelected(event) {
this.dispatchEvent(new CustomEvent('toggleselected', {
detail: {
alarmId: event.currentTarget.dataset.alarmId
}
}));
}
}
<!-- alarmList.html -->
<template>
<template for:each={rows} for:item="row">
<article key={row.id} class="alarm-row">
<span>{row.name}</span>
<span>{row.severity}</span>
<lightning-button
data-alarm-id={row.id}
label={row.selectLabel}
onclick={handleToggleSelected}>
</lightning-button>
</article>
</template>
</template>
That raw component is easy to reuse:
- in Live Preview
- in Jest tests
- inside another page
- inside a demo harness
- inside a future Experience Cloud version where state managers are not available yet
- with a different state manager later
- with no state manager at all
That is exactly what I mean by "keep the UI raw".
The lock-in smell
This is the shape I would avoid for low-level UI components.
// alarmSeverityPill.js
import { LightningElement } from 'lwc';
import { fromContext } from '@lwc/state';
import alarmWorkspaceState from 'c/alarmWorkspaceState';
export default class AlarmSeverityPill extends LightningElement {
state = fromContext(alarmWorkspaceState);
get severity() {
return this.state.value.currentAlarm.severity;
}
}
That component now only makes sense inside one state-manager hierarchy.
It cannot be dropped into another page without bringing the state manager with it. It cannot be easily rendered with sample data. It cannot be reused for a different alarm source.
The raw version is better.
// alarmSeverityPill.js
import { LightningElement, api } from 'lwc';
export default class AlarmSeverityPill extends LightningElement {
@api severity;
get badgeClass() {
return this.severity === 'Critical'
? 'severity-pill severity-pill_critical'
: 'severity-pill';
}
}
<!-- alarmSeverityPill.html -->
<template>
<lightning-badge label={severity} class={badgeClass}></lightning-badge>
</template>
Now the pill is just a pill.
That is good.
Old vs new comparison
Here is the trade-off as I would frame it.
The exciting bit is not that every component can read the same state manager.
The exciting bit is that not every component has to.
Graph: where each kind of state should live
flowchart LR A["Local UI state - menu open, input draft, focus state"] --> B["Component property"] C["Shared page state - filters, selected IDs, derived totals"] --> D["State manager"] E["Platform data - records, layouts, object metadata"] --> F["LDS, UI API, Apex, or built-in state managers"] G["Reusable presentation - row, badge, toolbar, empty state"] --> H["Raw props and events"]
This is the rule I would use in code review.
If the state is only meaningful inside one raw component, keep it local.
If it coordinates the page, move it up.
If it is durable Salesforce data, be deliberate about the data-access layer.
Built-in state managers are interesting too
Salesforce also provides built-in lightning/stateManager* modules for common LDS and UI API work, including record, layout, object info, and related-list state managers.
That is a strong signal about where this is going.
The platform wants data access and data coordination to be less scattered across individual components.
For example, a detail panel could compose record and layout state rather than making every component wire its own little slice of the world.
flowchart TD A["recordId and objectApiName"] B["stateManagerRecord - minimal record"] C["stateManagerLayout - compact layout"] D["stateManagerRecord - layout fields"] E["computed data shape - ready for UI"] F["raw detail panel - props only"] A --> B B --> C B --> D C --> D D --> E E --> F
That is much easier to reason about than five components each asking for overlapping data and hoping LDS smooths it all out.
But again: I would still keep the final detail panel raw.
What I would use state managers for
Good candidates:
- a console page with shared filters and row selection
- a multi-step wizard
- a shopping cart or quote builder
- a dashboard with coordinated panels
- a record workspace where several components depend on the same metadata
- a page where derived values are duplicated in several places
- a workflow where actions must keep several values consistent
Weak candidates:
- a single form field
- a tiny card with one Apex call
- a parent passing two values to one child
- hover, focus, and open/closed UI state
- presentation-only components
State managers should remove coordination pain.
They should not become ceremony.
My rule of thumb
I would split components into three rough layers.
That is the version I am excited about.
It keeps the state layer powerful, but it does not let it leak everywhere.
Final take
State managers are one of the best Summer '26 developer topics because they are not just a nicer syntax trick.
They give LWC teams a better architectural option for pages that have outgrown pure props and events.
But the restraint matters.
Use state managers to centralise shared data logic.
Use adaptors to connect that logic to the component tree.
Keep the raw UI components boring, portable, and usable without the state manager.
That is how this becomes a durable pattern instead of a new kind of lock-in.
Sources
- Salesforce Summer '26 release notes: Lightning Components
- Salesforce LWC Developer Guide: Manage State Across LWC Components with State Managers
- Salesforce LWC Developer Guide: State Management Compared with Alternatives
- Salesforce LWC Developer Guide: Define a State Manager
- Salesforce LWC Developer Guide: lightning/stateManager* State Managers
- Salesforce state-management examples on GitHub