Skip to content

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 FetchableComponent base 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 connectedCallback fires. Set up subscriptions, timers, and event listeners here.
  • hostDisconnected(): called when the host component's disconnectedCallback fires. Clean up resources.
  • hostUpdate(): called before the host's render() method. Pre-render controller logic.
  • hostUpdated(): called after the host's render() method, before firstUpdated(). 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 hostUpdate runs at SSR (pre-render), but its connection and post-render hooks (hostConnected / hostUpdated / hostDisconnected) run only on the client.

Next Steps