Reactive Controllers
Reactive controllers are a composition pattern for sharing lifecycle-bound logic across components without using inheritance. Instead of building mixin chains or base class hierarchies, you create standalone controller objects that hook into any component's lifecycle.
Why the lit-shaped hook names? WebJs adopts lit's hostConnected / hostDisconnected / hostUpdate / hostUpdated protocol verbatim because AI coding agents have substantial training data on lit. Matching lit's API names means agents emit idiomatic WebJs code without framework-specific translation, and any lit ReactiveController found in the wild is drop-in compatible here.
What Controllers Solve
Consider a scenario where three different components all need to fetch data on connect, poll on an interval, and clean up on disconnect. Without controllers, your options are:
- Inheritance: create a
FetchableComponentbase class. But what if a component needs both fetching and resize observation? Multiple inheritance is not possible, and deep class chains are fragile. - Mixins: works but gets messy with multiple mixins fighting over the same lifecycle methods and naming collisions.
- Copy-paste: duplicated logic across components, violating DRY.
Controllers solve this cleanly: each controller is an independent object that registers itself with a host component via addController(). The component automatically calls the controller's lifecycle methods at the right time. Multiple controllers coexist on the same component without conflict.
The Controller Protocol
A controller is any object that implements some or all of these methods:
- hostConnected(): called when the host component's
connectedCallbackfires. Set up subscriptions, timers, and event listeners here. - hostDisconnected(): called when the host component's
disconnectedCallbackfires. Clean up resources. - hostUpdate(): called before the host's
render()method. Pre-render controller logic. - hostUpdated(): called after the host's
render()method, beforefirstUpdated(). Post-render controller logic.
All methods are optional. Implement only the ones your controller needs.
Creating a Custom Controller
Here is a minimal controller that tracks the host element's visibility via IntersectionObserver:
class VisibilityController {
constructor(host, options = {}) {
this.host = host;
this.isVisible = false;
this._options = options;
this._observer = null;
host.addController(this); // register with the host
}
hostConnected() {
this._observer = new IntersectionObserver(
([entry]) => {
this.isVisible = entry.isIntersecting;
this.host.requestUpdate(); // tell the host to re-render
},
{ threshold: this._options.threshold ?? 0 },
);
this._observer.observe(this.host);
}
hostDisconnected() {
this._observer?.disconnect();
this._observer = null;
}
}
Usage in any component:
import { WebComponent, html, css } from '@webjsdev/core';
class LazyImage extends WebComponent({ src: String }) {
#visibility = new VisibilityController(this, { threshold: 0.1 });
constructor() {
super();
this.src = '';
}
render() {
return html`
${this.#visibility.isVisible
? html`<img src=${this.src} alt="" />`
: html`<div class="placeholder">Loading...</div>`}
`;
}
}
LazyImage.register('lazy-image');
Example: FetchController
A reusable controller that fetches data from a URL and exposes loading/error/data states:
class FetchController {
constructor(host, url) {
this.host = host;
this.url = url;
this.data = null;
this.error = null;
this.loading = false;
host.addController(this);
}
async hostConnected() {
this.loading = true;
this.host.requestUpdate();
try {
const res = await fetch(this.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.data = await res.json();
this.error = null;
} catch (e) {
this.error = e;
this.data = null;
} finally {
this.loading = false;
this.host.requestUpdate();
}
}
hostDisconnected() {
// Could abort an in-flight request here if using AbortController
}
}
Any component can now fetch data by creating a FetchController instance:
import { WebComponent, html } from '@webjsdev/core';
class UserList extends WebComponent {
#users = new FetchController(this, '/api/users');
render() {
if (this.#users.loading) return html`<p>Loading...</p>`;
if (this.#users.error) return html`<p>Error: ${this.#users.error.message}</p>`;
return html`
<ul>
${this.#users.data?.map(u => html`<li>${u.name}</li>`)}
</ul>
`;
}
}
UserList.register('user-list');
Multiple Controllers on One Component
Controllers compose naturally. A single component can use any number of controllers:
class DashboardWidget extends WebComponent {
#data = new FetchController(this, '/api/dashboard/stats');
#visibility = new VisibilityController(this, { threshold: 0.5 });
#timer = new IntervalController(this, 30000, () => this.refresh());
refresh() {
// Re-fetch data
}
render() {
if (!this.#visibility.isVisible) return html`<div class="offscreen"></div>`;
if (this.#data.loading) return html`<p>Loading...</p>`;
return html`<div>${this.#data.data?.summary}</div>`;
}
}
DashboardWidget.register('dashboard-widget');
Built-in Controllers
WebJs ships three controllers out of the box:
Task
Manages async operations with automatic loading/error states, abort support, and reactive args. Imported from webjs/task. See the Task Controller page for full documentation.
import { Task } from '@webjsdev/core/task';
class UserProfile extends WebComponent({ userId: String }) {
#task = new Task(this, {
task: async ([id], { signal }) => {
const res = await fetch(`/api/users/${id}`, { signal });
return res.json();
},
args: () => [this.userId],
});
render() {
return this.#task.render({
pending: () => html`<p>Loading...</p>`,
complete: (user) => html`<h1>${user.name}</h1>`,
error: (e) => html`<p>Error: ${e.message}</p>`,
});
}
}
ContextProvider
Provides a value to all descendant components via the context protocol. Imported from webjs/context. See the Context Protocol page for full documentation.
import { createContext, ContextProvider } from '@webjsdev/core/context';
const themeContext = createContext('theme');
class AppShell extends WebComponent {
#themeProvider = new ContextProvider(this, {
context: themeContext,
initialValue: 'light',
});
toggleTheme() {
const next = this.#themeProvider.value === 'light' ? 'dark' : 'light';
this.#themeProvider.setValue(next);
}
}
ContextConsumer
Consumes a value from an ancestor provider. Imported from webjs/context.
import { createContext, ContextConsumer } from '@webjsdev/core/context';
const themeContext = createContext('theme');
class ThemeBadge extends WebComponent {
#theme = new ContextConsumer(this, {
context: themeContext,
subscribe: true, // re-render when the value changes
});
render() {
return html`<span>Current theme: ${this.#theme.value}</span>`;
}
}
When to Use Controllers
- Reusable lifecycle logic: fetch, timer, subscription, resize observer, intersection observer, media query, keyboard shortcuts.
- Cross-cutting concerns: logging, analytics, performance monitoring that multiple components need.
- Avoiding deep inheritance: when a component needs behavior from multiple sources, controllers compose where inheritance cannot.
When NOT to Use Controllers
- Simple one-off logic: if only one component needs the behavior, put it directly in the component. Don't over-abstract.
- Shared rendering: controllers don't produce templates. For reusable UI, create a component. Controllers are for reusable behavior.
- Page-level data: use async page functions for server-side data loading. A controller's
hostUpdateruns at SSR (pre-render), but its connection and post-render hooks (hostConnected/hostUpdated/hostDisconnected) run only on the client.
Next Steps
- Context Protocol: cross-component data sharing without prop drilling
- Task Controller: async data fetching with loading/error states
- Lifecycle Hooks: the full component update cycle