State Management
How to share state between web components: Lit reactive controllers for reusable logic, the context protocol for tree-scoped state, and when to reach for a third-party library.
After the data fetching patterns in Lit clicked, I hit a wall. A single component managing its own state is solved. The question I kept coming back to was: what happens when two components need the same value?
Web components have no built-in global store. That is a real gap, and the ecosystem is still filling it.
The Problem
Each web component is an isolated unit. Its shadow root encapsulates markup, styles, and state. Two sibling components cannot reach into each other's internals -- they communicate through events and properties, not shared memory.
This works fine at the component level. It becomes a problem the moment a user session, a shopping cart, or a theme needs to live in multiple components simultaneously.
Reactive Controllers
Lit's answer to extracting logic from a component is the reactive controller. A controller is a class that hooks into a component's update cycle. It has its own lifecycle and manages its own state, but it is attached to a host.
class TimerController {
constructor(host) {
this.host = host;
host.addController(this);
this.elapsed = 0;
}
hostConnected() {
this._interval = setInterval(() => {
this.elapsed++;
this.host.requestUpdate();
}, 1000);
}
hostDisconnected() {
clearInterval(this._interval);
}
}In a Lit component:
class CountdownTimer extends LitElement {
_timer = new TimerController(this);
render() {
return html`<p>Elapsed: ${this._timer.elapsed}s</p>`;
}
}The controller manages its own interval and cleanup. The component just reads from it.
This is custom-hook energy -- stateful logic extracted from the component without leaving the reactivity model. Any Lit component can new TimerController(this) and get the same behavior. Controllers are well-suited for mouse tracking, resize observation, WebSocket connections, form validation -- anything with its own lifecycle that is not a full component.
ExpandReactive controller as a reusable host-attached unit, and the context protocol for tree-scoped state sharing
The Context Protocol
A controller solves per-component reusable logic. It does not solve "I need a user session in component A and in component B on the other side of the page."
The Web Components Community Group has a proposed context protocol that handles this with events. A provider element listens for context requests. A consumer dispatches a request and stores what it receives.
// Provider -- somewhere up the DOM tree
this.addEventListener('context-request', (e) => {
if (e.context === 'current-user') {
e.stopPropagation();
e.callback({ name: 'Denver', role: 'admin' });
}
});
// Consumer -- any component that needs the value
this.dispatchEvent(new ContextRequestEvent(
'current-user',
(value) => { this.user = value; }
));The consumer does not import the provider. It dispatches an event and any ancestor can answer.
A different provider can supply the same context key without the consumer changing. The components are loosely coupled.
The specification is still being developed. @lit/context ships an implementation that follows the protocol today. Adobe's Spectrum Web Components uses it in production.
When to Reach for More
Reactive controllers cover per-component reusable logic. The context protocol covers tree-scoped shared values. If you need full global state management -- Redux-style actions, time-travel debugging, derived selectors -- bring your own state manager.
Zustand works cleanly with Lit through a reactive controller wrapper. A simple event bus wired through a controller gives you global state with minimal overhead. The web component does not dictate the state layer -- that is an application decision.
FAST, Microsoft's web component framework, explored dependency injection for state sharing. The Lit community has built several reactive state packages. The primitives are there -- the ergonomics are still maturing.
Not having global state built in is a legitimate trade-off reason to choose a full framework over raw web components for complex applications. Next.js, Nuxt, and SvelteKit bring their own state solutions. That convenience has a cost -- you are deeper in a specific ecosystem.
Neither answer is wrong.
The component model is mostly solid. The rougher edges are elsewhere: accessibility across the shadow boundary, server-side rendering, and how search engines index the content.
The Essentials
- Web components have no built-in global store. Each component's state is isolated by design.
- Lit reactive controllers extract stateful logic from a component without leaving the reactivity model. They attach to a host and hook into its lifecycle. Think custom hooks.
- The context protocol is event-driven: consumers dispatch a request up the DOM tree, providers intercept and respond. The components never import each other.
@lit/contextis the current implementation of the context protocol. Available today, spec still being finalized.- For full global state, bring your own. Zustand, a simple event bus, or any state library wired through a reactive controller all work.
- The absence of global state is a real trade-off. For complex applications, a full framework may be the right call.
Further Reading and Watching
- Lit Labs: Context -- walkthrough of the context protocol and
@lit-labs/contextpackage from the Lit team - Reactive Controllers -- Lit documentation -- full reference for the ReactiveController interface, host lifecycle integration, and composing multiple controllers
Keep reading