A Lit ResizeObserver controller registers itself with the host, starts observing in hostConnected, stops in hostDisconnected, stores the latest size, and calls host.requestUpdate() only when a value the template actually uses — typically a breakpoint — changes.

Problem / Scenario Context

A design system built with Lit has a <ds-toolbar> that shows full buttons, icon-only buttons or an overflow menu depending on its width, and a <ds-chart> that redraws at its container's size. Each component has its own copy of a ResizeObserver setup with slightly different bugs: one never disconnects, one re-renders on every pixel of a drag-resize, one observes in the constructor and misses elements that are upgraded late.

Lit's reactive controllers exist to package this kind of cross-cutting lifecycle logic once. The Web Component & Lit Observer Controllers topic introduces the model; this page builds a production controller.

Mechanics Explanation

A reactive controller is an object implementing some of hostConnected(), hostDisconnected(), hostUpdate() and hostUpdated(). Calling host.addController(controller) wires it in: Lit calls hostConnected from the element's connectedCallback (and immediately if the host is already connected), hostDisconnected from disconnectedCallback, and the update hooks around each render.

A controller holds state and can ask for a re-render with host.requestUpdate(). Lit batches updates into a microtask, so several requests before the next update produce one render.

For a ResizeObserver controller that means:

  • Observe in hostConnected, not in the constructor, so late upgrades and moves work.
  • Re-render selectively. Resize callbacks can fire every frame during a drag; requesting an update each time re-renders the template each frame. Compare against the last rendered value and only request an update when it changes.
  • Choose what to observe. The host is the default; a shadow part (such as the chart's canvas container) can be observed after the first render, in hostUpdated.

Controller Lifecycle Inside a Lit ElementFour boxes. The element constructor creates the controller, which registers with addController. When the host connects, the controller starts observing. Each resize callback computes a breakpoint and requests an update only if it changed. When the host disconnects, the controller stops observing.constructornewSizeController(this)hostConnectedobserve(host)RO callbackrequestUpdate only onchangehostDisconnectedunobserve(host)

Comparison Table: Controller Design Choices

Choice Option A Option B Recommendation
Observer instance one per controller one shared, module-level shared for many instances
Re-render trigger every callback only on breakpoint change breakpoint for layout; raw size for drawing
Target host shadow part host unless a part has its own size
Size source contentRect contentBoxSize[0] / borderBoxSize[0] box sizes; logical axes
Drawing work in render() in the callback, imperatively callback, for canvas and charts

Minimal Reproducible Example

TypeScript
@customElement('ds-toolbar')
class Toolbar extends LitElement {
  @state() width = 0;
  constructor() {
    super();
    new ResizeObserver(([e]) => { this.width = e.contentRect.width; }).observe(this);   // never released
  }
  render() {
    return this.width < 400 ? html`<ds-overflow-menu></ds-overflow-menu>` : html`<slot></slot>`;
  }
}

Dragging the window edge re-renders the toolbar every frame, because width is reactive state that changes by fractions of a pixel. Removing the toolbar leaves the observer attached.

Production-Safe Solution

TypeScript
import type { ReactiveController, ReactiveControllerHost } from 'lit';

type Host = ReactiveControllerHost & HTMLElement;
type Callback = (entry: ResizeObserverEntry) => void;

// One ResizeObserver for every controller instance on the page.
const callbacks = new WeakMap<Element, Callback>();
const sharedRO = new ResizeObserver((entries) => {
  for (const e of entries) callbacks.get(e.target)?.(e);
});

export interface SizeControllerOptions<B extends string> {
  breakpoints: Record<B, number>;          // e.g. { compact: 0, medium: 400, wide: 720 }
  target?: (host: Host) => Element | null; // defaults to the host
  box?: ResizeObserverBoxOptions;
  onSize?: (inline: number, block: number) => void;   // imperative, per callback
}

export class SizeController<B extends string> implements ReactiveController {
  breakpoint: B;
  inline = 0;
  block = 0;
  #target: Element | null = null;

  constructor(private host: Host, private opts: SizeControllerOptions<B>) {
    this.breakpoint = this.#pick(0);
    host.addController(this);
  }

  #pick(width: number): B {
    let best = Object.keys(this.opts.breakpoints)[0] as B;
    for (const [name, min] of Object.entries(this.opts.breakpoints) as [B, number][]) {
      if (width >= min) best = name;
    }
    return best;
  }

  hostConnected(): void {
    // Observe the host now; a shadow part is picked up after the first render.
    if (!this.opts.target) this.#observe(this.host);
  }

  hostUpdated(): void {
    if (!this.opts.target) return;
    const t = this.opts.target(this.host);
    if (t && t !== this.#target) this.#observe(t);
  }

  hostDisconnected(): void {
    if (this.#target) { sharedRO.unobserve(this.#target); callbacks.delete(this.#target); }
    this.#target = null;
  }

  #observe(el: Element): void {
    if (this.#target) { sharedRO.unobserve(this.#target); callbacks.delete(this.#target); }
    this.#target = el;
    callbacks.set(el, (e) => {
      const box = (this.opts.box === 'border-box' ? e.borderBoxSize : e.contentBoxSize)[0];
      this.inline = box.inlineSize;
      this.block = box.blockSize;
      this.opts.onSize?.(this.inline, this.block);        // imperative drawing, no render
      const next = this.#pick(this.inline);
      if (next !== this.breakpoint) {
        this.breakpoint = next;
        this.host.requestUpdate();                        // re-render only at breakpoints
      }
    });
    sharedRO.observe(el, { box: this.opts.box ?? 'content-box' });
  }
}
TypeScript
// Usage
@customElement('ds-toolbar')
class Toolbar extends LitElement {
  #size = new SizeController(this, { breakpoints: { compact: 0, medium: 400, wide: 720 } });
  render() {
    switch (this.#size.breakpoint) {
      case 'compact': return html`<ds-overflow-menu></ds-overflow-menu>`;
      case 'medium':  return html`<slot name="icons"></slot><ds-overflow-menu></ds-overflow-menu>`;
      default:        return html`<slot></slot>`;
    }
  }
}

@customElement('ds-chart')
class Chart extends LitElement {
  #size = new SizeController(this, {
    breakpoints: { any: 0 },
    target: (h) => h.renderRoot.querySelector('.plot'),
    onSize: (w, h) => this.#draw(w, h),                  // redraw every size change, no re-render
  });
  #draw(w: number, h: number): void { /* canvas drawing */ }
  render() { return html`<div class="plot"><canvas></canvas></div>`; }
}

The toolbar re-renders three times during a full-width drag — once per breakpoint crossed — instead of every frame. The chart never re-renders its template on resize; it redraws imperatively inside the resize callback, which runs before paint, so the drawing and the new size appear in the same frame.

Toolbar Renders During a Window DragA bar chart of how many times the toolbar re-rendered during a two-second drag from narrow to wide. With width as reactive state it rendered about one hundred and twenty times. With the breakpoint controller it rendered three times, once per breakpoint crossed.2-second drag from 300 px to 900 pxwidth as @state~120 rendersbreakpoint controller3 renders

Using @lit-labs/observers Instead

Lit's labs package includes a ResizeController. It is a sensible default for components that are not instantiated in large numbers:

TypeScript
import { ResizeController } from '@lit-labs/observers/resize-controller.js';

class Panel extends LitElement {
  #resize = new ResizeController(this, {
    callback: (entries) => entries[0]?.contentBoxSize[0].inlineSize ?? 0,
  });
  render() { return html`${this.#resize.value! < 400 ? 'narrow' : 'wide'}`; }
}

Two differences from the controller above: it creates one observer per controller instance, and it requests an update on every callback, storing the callback's return value in value. For a handful of panels that is fine; for a grid of hundreds of cards, a shared observer with breakpoint-only updates is noticeably cheaper.

Labs ResizeController Versus a Custom Shared ControllerTwo columns. The labs ResizeController is maintained by the Lit team, needs no custom code, creates one observer per component and requests an update on every callback. The custom shared controller shares one observer across all components, re-renders only at breakpoints and supports imperative drawing callbacks, at the cost of maintaining the code.@lit-labs/observers ResizeControllerMaintained by the Lit teamOne observer per component instancerequestUpdate on every callbackCustom shared controllerOne observer for the whole pageRe-renders only when a breakpoint changesonSize hook for imperative drawing

Verification Steps

  • Log renders (a counter in updated()) while drag-resizing; expect one per breakpoint crossed.
  • Count observer instances in a heap snapshot on a page with many toolbars: one.
  • Remove and re-add components and confirm the callbacks map does not grow.
  • Move a component to a different container and confirm it keeps reacting.
  • Check the chart stays crisp and aligned during resizes, with no one-frame lag.

Common Mistakes to Avoid

  • Observing in the constructor. Late upgrades and moves break; use hostConnected.
  • Making raw width reactive state. Every sub-pixel change becomes a render.
  • Drawing in render(). Lit's update is a microtask later; draw in the resize callback for same-frame results.
  • Querying shadow parts before the first render. They do not exist yet; resolve them in hostUpdated.

FAQ

When does Lit call hostConnected?

From the host's connectedCallback, and immediately inside addController if the host is already connected. It runs on every connection, so moves and re-insertions re-establish observation.

Can a controller observe something inside the shadow root?

Yes, after the first render has created it. Resolve the element in hostUpdated, as the target option above does, and switch observation if a later render replaces it.

Why not use CSS container queries instead?

For pure styling changes, do. Container queries cannot change which template renders — swapping buttons for an overflow menu, for example — and cannot drive canvas drawing, which is where the controller earns its place.

Is requestUpdate expensive?

It schedules one asynchronous update per host per microtask, so repeated calls are coalesced. The cost is the render itself, which is why limiting requests to meaningful changes matters.

How do I test a reactive controller in isolation?

Create a minimal Lit element in the test that instantiates the controller, mount it into a sized container, change the container's width, wait for two animation frames, and assert on the controller's breakpoint and the host's rendered output. Real ResizeObserver behaviour needs a browser-based runner such as Web Test Runner or Playwright.

Does sharing one ResizeObserver change delivery timing?

No. All observers deliver in the same rendering steps. Sharing only reduces instance and closure overhead, and delivers all changed components in one callback.


↑ Back to Web Component & Lit Observer Controllers