Skip to content
HELiX

State Management

apps/docs/src/content/docs/components/advanced/state-management Click to copy
Copied! apps/docs/src/content/docs/components/advanced/state-management

State management is the foundation of component interactivity. This guide covers local state, shared state, context API patterns, reactive controllers, and cross-component communication strategies used throughout hx-library.

Reading note: Several recipes below use consumer-owned custom-element names (org-wizard, org-mouse-tracker, org-cart-badge, org-product-card, org-theme-toggle, org-search-results) — those tag names aren’t shipped components in @helixui/library, treat them as patterns to rename for your own codebase. The recipes also reference platform/library APIs (@lit/context, RxJS, signals) that you’d compose against your own dependency tree; HELiX itself does not import @lit/context and the shipped radio-group coordination uses internal custom events, not the context API. Inline corrections call out the highest-impact mismatches against the shipped HELiX surface — e.g. hx-dropdown.open is a public reflected property (not private @state), hx-image does publicly fire hx-load, hx-dialog toggles its open boolean (no dialog.open() method on top of the boolean), hx-counter’s public state lives on value not count, and hx-radio-select is the internal coordination event inside hx-radio-group — consumers listen for hx-change on the group instead.


State management in web components differs from traditional frameworks because components are isolated by Shadow DOM and distributed across framework boundaries. In hx-library, we use:

  • Local state — Component-internal state using @state() decorator
  • Reactive properties — Public properties using @property() decorator
  • Reactive controllers — Reusable state/behavior encapsulation
  • Custom events — Component-to-component communication
  • Form-associated state — State synchronized via ElementInternals API
  • Context API — Shared state across component hierarchies (via @lit/context)

Key principles:

  1. Minimize shared state — Default to local state unless coordination is required.
  2. Use properties for public API — External state flows in via properties, flows out via events.
  3. Encapsulate logic in controllers — Reusable behaviors like adopted stylesheets, keyboard navigation, or timers belong in controllers.
  4. Respect platform patterns — Form state uses ElementInternals, not custom stores.

Local state is private to the component and does not appear as an attribute. Changes trigger reactive updates.

The example below uses a consumer-owned org-dropdown because the shipped hx-dropdown exposes open as a public reflected property (so consumers can drive it externally and CSS can target [open]), not a private @state field. The state-vs-property choice for any given component is a design decision — for HELiX overlays we generally lift “is the overlay visible” up to a public property:

import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@customElement('org-dropdown')
export class OrgDropdown extends LitElement {
@state() private _isOpen = false;
private _toggle(): void {
this._isOpen = !this._isOpen;
}
override render() {
return html`
<button @click=${this._toggle}>Toggle</button>
${this._isOpen ? html`<div class="dropdown-menu">Content</div>` : null}
`;
}
}

When to use @state():

  • Component-internal UI state (open/closed, focused, hover, etc.)
  • Derived or computed state (e.g., _hasError from slot detection)
  • Temporary state that never needs external access

Real-world example from hx-select:

@state() private _hasLabelSlot = false;
@state() private _hasErrorSlot = false;
private _handleLabelSlotChange(e: Event): void {
const slot = e.target as HTMLSlotElement;
this._hasLabelSlot = slot.assignedNodes({ flatten: true }).length > 0;
}

State reactivity:

Lit tracks changes to @state() properties. When you modify a @state() property, Lit schedules a re-render. This is batched — multiple state changes in the same task only trigger one render.


Public properties are part of the component’s API. They can be set via attributes, properties, or JavaScript.

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
// Illustrative — shipped hx-alert renders projected default-slot content
// rather than a `message` property. The reactive-properties shape is the
// teaching point here, not the real hx-alert API.
@customElement('example-alert')
export class ExampleAlert extends LitElement {
@property({ type: String })
variant: 'info' | 'success' | 'warning' | 'error' = 'info';
@property({ type: Boolean, reflect: true })
open = false;
override render() {
return html`
<div class="alert alert--${this.variant}" ?hidden=${!this.open}>
<slot></slot>
</div>
`;
}
}

Options:

  • type — Type converter (String, Number, Boolean, Array, Object)
  • reflect — Sync property changes back to attributes (e.g., for CSS selectors)
  • attribute — Custom attribute name (default: lowercase property name with dashes)
  • converter — Custom serialization/deserialization logic

Example from hx-checkbox:

@property({ type: Boolean, reflect: true })
checked = false;
@property({ type: String, attribute: 'help-text' })
helpText = '';

When to use @property():

  • Any value consumers need to configure (variant, disabled, label, etc.)
  • State that should reflect to the DOM (for styling or inspection)
  • Values that participate in form state (value, checked, etc.)

ScenarioUseReason
Open/closed state of a dropdown@state()Internal UI concern, not part of public API
Checked state of a checkbox@property()Public API, reflected to DOM, synced with forms
Slot content detection (_hasErrorSlot)@state()Internal rendering logic, no external access
Error message to display@property()Public API, configurable by consumers
Loading spinner visibility@state() (if async) or @property() (if controlled)Depends on whether parent controls it
Focus state@state()Internal UI state, managed by focus events

Reactive controllers are reusable encapsulations of state and behavior that hook into a component’s lifecycle. They implement the ReactiveController interface:

interface ReactiveController {
hostConnected?(): void;
hostDisconnected?(): void;
hostUpdate?(): void;
hostUpdated?(): void;
}

Controllers are registered via addController():

class MyElement extends LitElement {
private _myController = new MyController(this);
}
class MyController implements ReactiveController {
constructor(private host: ReactiveControllerHost) {
this.host.addController(this);
}
hostConnected(): void {
console.log('Component connected');
}
}

Real-World Example: Adopted Stylesheets Controller

Section titled “Real-World Example: Adopted Stylesheets Controller”

hx-library uses a controller to inject CSS into the document without Shadow DOM, enabling Light DOM components like hx-form to style native elements:

import { AdoptedStylesheetsController } from '../../controllers/adopted-stylesheets.js';
import { helixFormScopedCss } from './hx-form.styles.js';
@customElement('hx-form')
export class HelixForm extends LitElement {
override createRenderRoot(): HTMLElement {
return this; // Light DOM
}
private _styles = new AdoptedStylesheetsController(this, helixFormScopedCss, document);
// The controller automatically injects styles on connectedCallback
// and removes them on disconnectedCallback
}

Controller implementation:

export class AdoptedStylesheetsController implements ReactiveController {
private static _cache = new Map<string, CSSStyleSheet>();
private _sheet: CSSStyleSheet | undefined;
constructor(
private _host: ReactiveControllerHost & HTMLElement,
private _cssText: string,
private _root: Document | ShadowRoot = document,
) {
this._host.addController(this);
}
hostConnected(): void {
let sheet = AdoptedStylesheetsController._cache.get(this._cssText);
if (!sheet) {
sheet = new CSSStyleSheet();
sheet.replaceSync(this._cssText);
AdoptedStylesheetsController._cache.set(this._cssText, sheet);
}
this._sheet = sheet;
if (!this._root.adoptedStyleSheets.includes(sheet)) {
this._root.adoptedStyleSheets = [...this._root.adoptedStyleSheets, sheet];
}
}
hostDisconnected(): void {
if (this._sheet) {
this._root.adoptedStyleSheets = this._root.adoptedStyleSheets.filter(
(s) => s !== this._sheet,
);
}
}
}

Benefits:

  • Global deduplication (same CSS text reuses the same CSSStyleSheet)
  • Automatic lifecycle management
  • Reusable across multiple components
  • No manual connectedCallback/disconnectedCallback boilerplate
Use CaseControllerWhy
Global CSS injectionAdoptedStylesheetsControllerManages document-level state
Keyboard navigationRovingTabindexControllerReusable focus management
Mouse trackingMouseControllerExternal event subscription
Resize observationResizeControllerManages ResizeObserver lifecycle
Timers/intervalsTaskControllerAutomatic cleanup on disconnect
Media queriesMediaQueryControllerReactively updates based on breakpoint

Controller pattern is superior to mixins or HOCs because it:

  • Composes without inheritance
  • Encapsulates state without leaking to the component
  • Cleans up automatically

Properties flow down from parent to child:

<!-- Parent passes state via properties -->
<hx-text-input
label="Email"
value="${this.email}"
?disabled="${this.isSubmitting}"
error="${this.emailError}"
></hx-text-input>

Child component receives via @property():

@customElement('hx-text-input')
export class HelixTextInput extends LitElement {
@property({ type: String })
value = '';
@property({ type: Boolean })
disabled = false;
@property({ type: String })
error = '';
}

Child dispatches custom events:

@customElement('hx-checkbox')
export class HelixCheckbox extends LitElement {
private _handleChange(): void {
this.checked = !this.checked;
this.dispatchEvent(
new CustomEvent('hx-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked, value: this.value },
}),
);
}
}

Parent listens:

override render() {
return html`
<hx-checkbox
@hx-change=${this._handleCheckboxChange}
></hx-checkbox>
`;
}
private _handleCheckboxChange(e: CustomEvent<{ checked: boolean }>): void {
console.log('Checkbox changed:', e.detail.checked);
this.acceptedTerms = e.detail.checked;
}

Event naming convention:

  • Prefix: hx- (e.g., hx-change, hx-submit, hx-close)
  • Use past tense for completed actions (e.g., hx-loaded, not hx-load)
  • Always set bubbles: true and composed: true (crosses Shadow DOM)

hx-library form controls use the ElementInternals API for native form participation. This is platform state management — no custom store required.

@customElement('hx-checkbox')
export class HelixCheckbox extends LitElement {
static formAssociated = true;
private _internals: ElementInternals;
@property({ type: Boolean, reflect: true })
checked = false;
@property({ type: String })
value = 'on';
@property({ type: String })
name = '';
constructor() {
super();
this._internals = this.attachInternals();
}
override updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('checked') || changedProperties.has('value')) {
// Sync form value
this._internals.setFormValue(this.checked ? this.value : null);
this._updateValidity();
}
}
private _updateValidity(): void {
if (this.required && !this.checked) {
this._internals.setValidity({ valueMissing: true }, 'This field is required.', this._inputEl);
} else {
this._internals.setValidity({});
}
}
formResetCallback(): void {
this.checked = false;
this._internals.setFormValue(null);
}
formStateRestoreCallback(state: string): void {
this.checked = state === this.value;
}
}

Key methods:

  • setFormValue(value) — Updates the form’s value for this field
  • setValidity(flags, message, anchor) — Updates validation state
  • checkValidity() — Returns true if valid
  • reportValidity() — Shows native validation UI

Benefits:

  • Native browser validation UI
  • Works with <form> submission (HTTP POST)
  • Integrates with Constraint Validation API
  • State persists across back/forward navigation (formStateRestoreCallback)

Siblings communicate via a shared parent:

@customElement('org-wizard')
export class HelixWizard extends LitElement {
@state() private _currentStep = 0;
override render() {
return html`
<org-wizard-step ?active=${this._currentStep === 0} @hx-next=${this._handleNext}>
Step 1
</org-wizard-step>
<org-wizard-step
?active=${this._currentStep === 1}
@hx-next=${this._handleNext}
@hx-previous=${this._handlePrevious}
>
Step 2
</org-wizard-step>
`;
}
private _handleNext(): void {
this._currentStep++;
}
private _handlePrevious(): void {
this._currentStep--;
}
}

Pattern:

  1. Parent holds shared state (_currentStep)
  2. Parent passes state down to children via properties (?active)
  3. Children dispatch events (hx-next, hx-previous)
  4. Parent updates state, triggering re-render
  5. Children receive updated properties

For deeply nested components (e.g., radio group managing individual radios), use @lit/context:

Install:

Terminal window
npm install @lit/context

Create a context:

import { createContext } from '@lit/context';
export interface RadioGroupContext {
name: string;
value: string;
select: (value: string) => void;
}
export const radioGroupContext = createContext<RadioGroupContext>(Symbol('radio-group-context'));

Provide context (parent):

import { provide } from '@lit/context';
import { radioGroupContext } from './context.js';
@customElement('hx-radio-group')
export class HelixRadioGroup extends LitElement {
@property({ type: String })
value = '';
@provide({ context: radioGroupContext })
private _context: RadioGroupContext = {
name: this.name,
value: this.value,
select: (value: string) => {
this.value = value;
},
};
override updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('value')) {
this._context = { ...this._context, value: this.value };
}
}
}

Consume context (child):

import { consume } from '@lit/context';
import { radioGroupContext, RadioGroupContext } from './context.js';
@customElement('hx-radio')
export class HelixRadio extends LitElement {
@consume({ context: radioGroupContext, subscribe: true })
@state()
private _groupContext?: RadioGroupContext;
private _handleClick(): void {
this._groupContext?.select(this.value);
}
override render() {
const isChecked = this._groupContext?.value === this.value;
return html`
<input
type="radio"
name=${this._groupContext?.name ?? ''}
?checked=${isChecked}
@click=${this._handleClick}
/>
`;
}
}

When to use Context API:

  • Parent-child relationships with deep nesting (e.g., radio group, tabs, accordion)
  • Avoiding prop drilling through intermediate components
  • Theming (light/dark mode)
  • Localization (language strings)

When NOT to use Context API:

  • Sibling-to-sibling communication (use parent state + events instead)
  • Global application state (use a framework store like Redux/Zustand)
  • Form state (use ElementInternals)

State machines model component behavior as a finite set of states and transitions. This eliminates invalid states and improves testability.

States:

  • closedopeningopenclosingclosed

Implementation:

type DialogState = 'closed' | 'opening' | 'open' | 'closing';
@customElement('hx-dialog')
export class HelixDialog extends LitElement {
@state() private _state: DialogState = 'closed';
async open(): Promise<void> {
if (this._state !== 'closed') return;
this._state = 'opening';
await this.updateComplete;
// Wait for animation
await new Promise((resolve) => setTimeout(resolve, 200));
this._state = 'open';
}
async close(): Promise<void> {
if (this._state !== 'open') return;
this._state = 'closing';
await this.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
this._state = 'closed';
}
override render() {
const classes = {
dialog: true,
'dialog--opening': this._state === 'opening',
'dialog--open': this._state === 'open',
'dialog--closing': this._state === 'closing',
};
return this._state === 'closed' ? null : html`<div class=${classMap(classes)}>Content</div>`;
}
}

Benefits:

  • No invalid states (e.g., “opening while closing”)
  • Transitions are explicit and auditable
  • Animation timing is coordinated with state

For complex state machines, consider:

  • XState — Visual state machine library with editor
  • Robot — Lightweight finite state machine
  • Custom reducer — Simple switch statement
type Action = { type: 'OPEN' } | { type: 'CLOSE' };
function dialogReducer(state: DialogState, action: Action): DialogState {
switch (action.type) {
case 'OPEN':
return state === 'closed' ? 'opening' : state;
case 'CLOSE':
return state === 'open' ? 'closing' : state;
default:
return state;
}
}

For fine-grained reactivity, use getters and setters:

@customElement('hx-counter')
export class HelixCounter extends LitElement {
private _count = 0;
get count(): number {
return this._count;
}
set count(value: number) {
const oldValue = this._count;
this._count = Math.max(0, value); // Clamp to non-negative
// Manual reactive update
this.requestUpdate('count', oldValue);
// Side effect: log to analytics
console.log('Count changed:', this._count);
}
override render() {
return html`<div>Count: ${this.count}</div>`;
}
}

When to use accessors:

  • Input validation (e.g., clamping, coercion)
  • Side effects on change (analytics, localStorage sync)
  • Computed properties based on multiple inputs

For complex async state, integrate RxJS:

import { fromEvent, map } from 'rxjs';
@customElement('org-mouse-tracker')
export class HelixMouseTracker extends LitElement {
@state() private _x = 0;
@state() private _y = 0;
override connectedCallback(): void {
super.connectedCallback();
const move$ = fromEvent<MouseEvent>(document, 'mousemove').pipe(
map((e) => ({ x: e.clientX, y: e.clientY })),
);
this._subscription = move$.subscribe(({ x, y }) => {
this._x = x;
this._y = y;
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._subscription?.unsubscribe();
}
}

Note: For simple cases, a controller is cleaner than RxJS.


Use a store when:

  • State is shared across unrelated components (no parent-child relationship)
  • State persists across route changes or page reloads
  • State requires centralized business logic (e.g., authentication, cart)

Do NOT use a store for:

  • Form state (use ElementInternals)
  • Parent-child communication (use properties and events)
  • UI state (open/closed, focus, hover)

Lit 3.x introduced signals (via @lit-labs/preact-signals):

Terminal window
npm install @lit-labs/preact-signals @preact/signals-core

Create a store:

store.ts
import { signal, computed } from '@preact/signals-core';
export const cartItems = signal<Array<{ id: string; name: string }>>([]);
export const cartCount = computed(() => cartItems.value.length);
export function addToCart(item: { id: string; name: string }): void {
cartItems.value = [...cartItems.value, item];
}
export function removeFromCart(id: string): void {
cartItems.value = cartItems.value.filter((item) => item.id !== id);
}

Consume in components:

import { SignalWatcher } from '@lit-labs/preact-signals';
import { cartCount, addToCart } from './store.js';
@customElement('org-cart-badge')
export class HelixCartBadge extends SignalWatcher(LitElement) {
override render() {
return html`<hx-badge>${cartCount.value}</hx-badge>`;
}
}
@customElement('org-product-card')
export class HelixProductCard extends LitElement {
@property({ type: String })
productId = '';
private _handleAddToCart(): void {
addToCart({ id: this.productId, name: 'Product Name' });
}
override render() {
return html` <hx-button @click=${this._handleAddToCart}> Add to Cart </hx-button> `;
}
}

Benefits:

  • Automatic reactivity (components re-render when signals change)
  • No prop drilling
  • Framework-agnostic (works in React, Vue, etc.)

Sync store to localStorage:

import { signal, effect } from '@preact/signals-core';
export const theme = signal<'light' | 'dark'>(
(localStorage.getItem('theme') as 'light' | 'dark') || 'light',
);
// Persist to localStorage on change
effect(() => {
localStorage.setItem('theme', theme.value);
});

@customElement('org-theme-toggle')
export class HelixThemeToggle extends LitElement {
@property({ type: String })
theme: 'light' | 'dark' = 'light';
override connectedCallback(): void {
super.connectedCallback();
this.theme = (localStorage.getItem('theme') as 'light' | 'dark') || 'light';
}
private _toggle(): void {
this.theme = this.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', this.theme);
}
}

Same API as localStorage, but cleared when the tab closes:

sessionStorage.setItem('wizard-step', '3');
const step = sessionStorage.getItem('wizard-step');

For shareable/bookmarkable state:

@customElement('org-search-results')
export class HelixSearchResults extends LitElement {
@property({ type: String })
query = '';
override connectedCallback(): void {
super.connectedCallback();
const params = new URLSearchParams(window.location.search);
this.query = params.get('q') || '';
}
private _handleSearch(e: CustomEvent<{ query: string }>): void {
this.query = e.detail.query;
const url = new URL(window.location.href);
url.searchParams.set('q', this.query);
window.history.pushState({}, '', url);
}
}

Start with @state() until you need external access:

// Good
@state() private _isOpen = false;
// Bad (unless external control is required)
@property({ type: Boolean }) isOpen = false;

Always create new objects/arrays for state updates:

// Good
this.items = [...this.items, newItem];
// Bad (mutation)
this.items.push(newItem); // Lit won't detect the change

Multiple state changes in the same task batch into one render:

private _handleSubmit(): void {
this.loading = true;
this.error = '';
this.submitted = true;
// Only renders once
}

Don’t copy prop values into state unless transformation is required:

// Bad
@property() count = 0;
@state() private _count = 0;
override updated(changedProps: Map<string, unknown>): void {
if (changedProps.has('count')) {
this._count = this.count; // Unnecessary duplication
}
}
// Good (just use the property directly)
@property() count = 0;

Derive state instead of storing it:

// Bad
@state() private _hasErrors = false;
override updated(): void {
this._hasErrors = this.errors.length > 0;
}
// Good
private get _hasErrors(): boolean {
return this.errors.length > 0;
}

6. Encapsulate Complex State in Controllers

Section titled “6. Encapsulate Complex State in Controllers”

If state management logic exceeds ~20 lines, extract to a controller:

// Instead of inline logic:
@customElement('hx-tooltip')
export class HelixTooltip extends LitElement {
@state() private _visible = false;
private _hoverTimeout?: number;
private _handleMouseEnter(): void {
clearTimeout(this._hoverTimeout);
this._hoverTimeout = window.setTimeout(() => {
this._visible = true;
}, 200);
}
private _handleMouseLeave(): void {
clearTimeout(this._hoverTimeout);
this._visible = false;
}
override disconnectedCallback(): void {
super.disconnectedCallback();
clearTimeout(this._hoverTimeout);
}
}
// Prefer a controller:
class TooltipController implements ReactiveController {
visible = false;
private _timeout?: number;
constructor(private host: ReactiveControllerHost) {
this.host.addController(this);
}
show(delay = 200): void {
clearTimeout(this._timeout);
this._timeout = window.setTimeout(() => {
this.visible = true;
this.host.requestUpdate();
}, delay);
}
hide(): void {
clearTimeout(this._timeout);
this.visible = false;
this.host.requestUpdate();
}
hostDisconnected(): void {
clearTimeout(this._timeout);
}
}

Parent manages shared state, children consume via events:

hx-radio-group.ts
@customElement('hx-radio-group')
export class HelixRadioGroup extends LitElement {
static formAssociated = true;
private _internals: ElementInternals;
@property({ type: String })
value = '';
@property({ type: String })
name = '';
constructor() {
super();
this._internals = this.attachInternals();
}
override connectedCallback(): void {
super.connectedCallback();
// Listen for child radio selections
this.addEventListener('hx-radio-select', this._handleRadioSelect);
}
override updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('value')) {
// Sync all child radios
this._syncRadios();
// Update form state
this._internals.setFormValue(this.value || null);
}
}
private _handleRadioSelect = (e: CustomEvent<{ value: string }>): void => {
e.stopPropagation();
const newValue = e.detail.value;
if (newValue === this.value) return;
this.value = newValue;
this.dispatchEvent(
new CustomEvent('hx-change', {
bubbles: true,
composed: true,
detail: { value: this.value },
}),
);
};
private _syncRadios(): void {
const radios = this.querySelectorAll('hx-radio');
radios.forEach((radio) => {
radio.checked = radio.value === this.value;
});
}
override render() {
return html`
<fieldset>
<legend>${this.label}</legend>
<slot></slot>
</fieldset>
`;
}
}
// hx-radio.ts
@customElement('hx-radio')
export class HelixRadio extends LitElement {
@property({ type: String })
value = '';
@property({ type: Boolean, reflect: true })
checked = false;
private _handleClick(): void {
this.dispatchEvent(
new CustomEvent('hx-radio-select', {
bubbles: true,
composed: true,
detail: { value: this.value },
}),
);
}
override render() {
return html`
<label @click=${this._handleClick}>
<input type="radio" .checked=${this.checked} @click=${(e: Event) => e.preventDefault()} />
<slot></slot>
</label>
`;
}
}

Usage:

<hx-radio-group name="color" value="blue">
<hx-radio value="red">Red</hx-radio>
<hx-radio value="green">Green</hx-radio>
<hx-radio value="blue">Blue</hx-radio>
</hx-radio-group>

State flow:

  1. User clicks radio → hx-radio dispatches hx-radio-select event
  2. hx-radio-group listens → updates value property
  3. updated() lifecycle → _syncRadios() updates all children’s checked property
  4. Form state synced via _internals.setFormValue()

PatternUse CaseImplementation
Local StateComponent-internal UI state@state()
Public PropertiesExternal configuration@property()
Reactive ControllersReusable behaviors/logicReactiveController
Parent-ChildDirect relationshipsProperties down, events up
Context APIDeeply nested hierarchies@lit/context
Form StateNative form participationElementInternals
Global StoreCross-component shared stateSignals, Redux, Zustand
State MachinesComplex state transitionsCustom reducer, XState
PersistenceState across sessionslocalStorage, URL params

Golden rules:

  1. Start simple — Default to @state() and @property()
  2. Lift state only when necessary — Avoid premature abstraction
  3. Prefer platform APIs — Use ElementInternals for forms, not custom stores
  4. Extract to controllers — Reusable logic belongs in controllers
  5. Events over callbacks — Use CustomEvent for component communication
  6. Immutability — Always create new objects/arrays for state updates
  7. Test state transitions — Write tests for every state change

State management in web components is declarative and composable. By following these patterns, you build components that are testable, maintainable, and framework-agnostic.