Mastering Lit: Reactive Controllers & Advanced Patterns

Beyond the Basics

While many developers know how to create a simple LitElement, few master the reactivity model that makes it powerful. This guide covers the nuances that distinguish a junior implementation from a senior one.

Property vs. State: The Critical Distinction

The @property() and @state() decorators both trigger updates, but their purpose differs fundamentally.

@property

Represents the public API of your component. It syncs with attributes by default.

  • Use when: Data is passed from a parent (HTML or another component).
  • Reflection: By default, properties reflect to attributes.
  • gotcha: Complex objects cannot be attributes. Use .prop syntax or a custom converter.
// HTML: <my-element user-id="123"></my-element>
@property({ type: Number, attribute: 'user-id' })
userId = 0; // "123" becomes 123

@state

Represents internal state. It is not exposed to the DOM as an attribute.

  • Use when: Data is private to the component (e.g., a counter, fetched data).
  • Optimization: Since it doesn't observe attributes, it's slightly cheaper.
@state()
protected _data: ComplexData | null = null;

Attribute vs Property Syntax

Beginners often confuse when to use attributes and when to use properties in templates.

  1. Attributes (name="value"): Always strings. Use for primitive configuration.
  2. Properties (.name=${value}): Direct JS assignment. MANDATORY for arrays/objects.
<!-- INCORRECT: Will likely result in "[object Object]" string -->
<my-child user="${this.userObj}"></my-child>

<!-- CORRECT: Passes the reference directly -->
<my-child .user="${this.userObj}"></my-child>

Reactive Controllers: Composition over Inheritance

Lit 2.0 introduced Reactive Controllers, a pattern to bundle state and logic into reusable units that hook into the host's lifecycle. Think of them as "Hooks for Classes".

Example: MouseController

Instead of handling mousemove in every component, create a controller:

export class MouseController implements ReactiveController {
  host: ReactiveControllerHost;
  pos = { x: 0, y: 0 };

  constructor(host: ReactiveControllerHost) {
    (this.host = host).addController(this);
  }

  hostConnected() {
    window.addEventListener("mousemove", this._onMove);
  }

  hostDisconnected() {
    window.removeEventListener("mousemove", this._onMove);
  }

  _onMove = (e: MouseEvent) => {
    this.pos = { x: e.clientX, y: e.clientY };
    this.host.requestUpdate(); // Trigger host re-render
  };
}

Usage:

class MyElement extends LitElement {
  private mouse = new MouseController(this);

  render() {
    return html`Mouse at: ${this.mouse.pos.x}, ${this.mouse.pos.y}`;
  }
}

Custom Converters

Sometimes standard JSON parsing isn't enough. You can define how an attribute string becomes a property value.

@property({
  converter: {
    fromAttribute: (value) => {
      // Handle "active,disabled" string -> ["active", "disabled"]
      return value ? value.split(',') : [];
    },
    toAttribute: (value) => {
      return value.join(',');
    }
  }
})
tags: string[] = [];

Performance: shouldUpdate

Prevent unnecessary renders by implementing shouldUpdate.

shouldUpdate(changedProps: PropertyValues) {
  if (changedProps.has('userId') && this.userId === 0) {
    return false; // Don't render initial invalid ID
  }
  return true;
}