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
.propsyntax 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.
- Attributes (
name="value"): Always strings. Use for primitive configuration. - 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;
}