Reactive controllers are Lit's answer to sharing stateful, lifecycle-aware logic across multiple components without inheritance. This lesson introduces the pattern and the interface behind it.
Sharing Logic Without Inheritance
Before controllers, sharing stateful logic (like 'track online/offline status' or 'sync with the URL') across multiple unrelated components meant either duplicating the code, or building a shared base class every component had to extend — which doesn't compose well if a component needs several unrelated shared behaviors at once.
A reactive controller is a plain JavaScript object implementing a small ReactiveController interface (optional hostConnected(), hostDisconnected(), hostUpdate(), hostUpdated() methods) that a component 'hosts' by calling this.addController(controllerInstance). The controller can then hook into that host component's own lifecycle and call host.requestUpdate() whenever its own internal state changes.
This controller can be reused by any component, unrelated to each other, that needs to know the browser's online/offline status.
Using a Controller from a Component
class StatusBanner extends LitElement {
#status = new OnlineStatusController(this);
render() {
return html`<p>${this.#status.isOnline ? 'Online' : 'Offline'}</p>`;
}
}
The controller instance is typically stored in a private field on the host component.
The controller's constructor calling host.addController(this) is what registers it to receive lifecycle callbacks.
render() reads the controller's own public state directly (this.#status.isOnline), just like a regular property.
A single component can host multiple, unrelated controllers side by side, unlike single-inheritance base classes.
ReactiveController Interface
The optional lifecycle methods a controller can implement.
Method
Called When
hostConnected()
The host component's connectedCallback runs
hostDisconnected()
The host component's disconnectedCallback runs
hostUpdate()
Just before the host's willUpdate/render
hostUpdated()
Just after the host's updated
host.requestUpdate()
Called BY the controller to trigger a host re-render
host.addController(this)
Called by the controller's constructor to register itself
Controllers vs. Mixins vs. Base Classes
Class mixins and shared base classes are two older patterns for code sharing in JavaScript, and both work with Lit too, but each has trade-offs controllers avoid: base classes only allow single inheritance (one component can only extend one shared base), and mixins, while composable, tie shared logic tightly to class structure and can be harder to reason about with TypeScript.
Pattern
Composability
Fits Lit's Model
Shared base class
Low — single inheritance only
Works, but limiting
Mixins
Medium — composable but structurally tangled
Works, more complex typing
Reactive controllers
High — freely composable, plain objects
Purpose-built for this exact use case
When a Controller Is the Right Tool
Reach for a controller specifically when logic needs to hook into a component's lifecycle (connect/disconnect/update) and is reusable across more than one, otherwise unrelated, component. If logic is genuinely specific to one component and doesn't need lifecycle hooks, a plain private method or helper function is simpler and sufficient.
Common Mistakes
Building a shared base class for logic that would compose more cleanly as a controller, especially once a component needs more than one shared behavior.
Forgetting to call host.requestUpdate() inside the controller after its internal state changes, so the host component never re-renders.
Not implementing hostDisconnected() to clean up subscriptions/listeners started in hostConnected(), leaking resources exactly like a missing disconnectedCallback would.
Reaching for a full controller for logic that's genuinely simple and specific to just one component, adding unnecessary abstraction.
Key Takeaways
Reactive controllers let you share stateful, lifecycle-aware logic across multiple, unrelated components.
A controller is a plain object implementing optional hostConnected/hostDisconnected/hostUpdate/hostUpdated hooks.
host.addController(this) registers a controller; host.requestUpdate() tells the host to re-render.
Controllers compose more freely than single-inheritance base classes or structurally-tangled mixins.
Pro Tip
Design controllers with the same care you'd give a small library's public API — a clean constructor, clearly named public fields/methods, and explicit lifecycle cleanup — since they're meant to be reused across components you may not be thinking about yet when you first write them.
You now understand reactive controllers conceptually. Next, look closer at the exact controller lifecycle hooks.