Lit 3 Web Components: Reactive Properties, Attribute Converters, Shadow DOM Styling and Lifecycle Pitfalls

Key takeaways

Lit is a thin layer over native custom elements: reactive properties, a tagged-template renderer and scoped styles. The API is small, so the bugs come from the platform underneath: attributes are strings, shadow DOM blocks your global CSS, class fields can silently disable reactivity, and setting properties in the wrong lifecycle hook causes extra renders. This guide covers each of those with the actual warnings Lit prints.

Lit is a small library for writing standard custom elements. It adds three things to the platform: reactive properties that schedule re-renders, a tagged-template renderer (html) that updates only the parts of the DOM that changed, and scoped styles (css) applied to the component’s shadow root. Everything else, including events, slots, forms and styling boundaries, is plain Web Components behaviour.

That thinness is the point, and it is also where the bugs come from. Most problems people hit with Lit are problems with the platform: attributes are strings, shadow DOM hides the component from your global CSS, and custom elements have their own lifecycle rules. This article covers those areas. The code targets Lit 3; the warning texts below are the ones Lit 3.3 prints in development mode. If you have not used custom elements without a library, the native Web Components article explains the underlying APIs.


A component, and the decorator configuration that breaks it

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('user-badge')
export class UserBadge extends LitElement {
  static styles = css`
    :host { display: inline-flex; gap: 0.5rem; align-items: center; }
  `;

  @property() name = '';
  @property({ type: Boolean, reflect: true }) online = false;
  @state() private expanded = false;

  render() {
    return html`
      <button @click=${() => (this.expanded = !this.expanded)}>${this.name}</button>
      ${this.expanded ? html`<slot></slot>` : null}
    `;
  }
}

Lit supports two decorator systems, and the TypeScript configuration decides which one you get:

  • Legacy (experimental) decorators: "experimentalDecorators": true and "useDefineForClassFields": false. The code above is written for this mode.
  • Standard decorators (TypeScript 5+, without experimentalDecorators): each reactive property needs the accessor keyword, as in @property() accessor name = '';.

Get this wrong and reactivity silently breaks. With useDefineForClassFields: true (the default when targeting ES2022 or later) and legacy decorators, the class field is defined on the instance and hides the reactive accessor Lit installed on the prototype. The same happens in plain JavaScript if you write count = 0 as a class field alongside a static properties block. In dev mode Lit catches it:

The following properties on element x-counter will not trigger updates as expected
because they are set using class fields: count. Native class fields and some compiled
output will overwrite accessors used for detecting changes.

In production builds there is no check. I reproduced this with the production bundle: setting the property changed the value, but the component kept rendering the initial one. This is the first thing I check when someone says “Lit doesn’t re-render”, ahead of any template logic, because it looks like a logic bug and is really a compiler setting. In plain JavaScript, initialize reactive properties in the constructor instead of as class fields.


Properties, attributes and converters

HTML attributes are always strings. A Lit @property is a JavaScript value that can optionally be linked to an attribute, and a converter translates between them. The defaults:

typeAttribute to propertyProperty to attribute (with reflect: true)
String (default)Value as isValue as is
NumberNumber(value); "abc" becomes NaNString(value)
Booleantrue if the attribute is presentAdds an empty attribute, or removes it for false
Object / ArrayJSON.parse(value); invalid JSON becomes null silentlyJSON.stringify(value)

Three consequences surprise people:

open="false" is true. The Boolean converter follows HTML: presence means true, the same as <input disabled="false"> being disabled. I verified it: setAttribute('open', 'false') produced el.open === true. Frameworks that render open={false} as the string "false" hit this. Remove the attribute instead, or bind the property.

Attribute names are lowercased property names. @property({ type: Number }) maxItems listens to the attribute maxitems, not max-items. If you want kebab case, say so: @property({ type: Number, attribute: 'max-items' }).

Do not pass objects through attributes. JSON in an attribute works for static markup, but parsing on every change is wasteful and invalid JSON fails quietly to null. Bind properties instead: in Lit templates, .items=${list} sets the property; items=${list} sets an attribute (stringified). For data-only properties, use attribute: false so there is no attribute at all.

For anything the defaults cannot express, provide a converter:

@property({
  converter: {
    fromAttribute: (v) => (v ? v.split(',').map((s) => s.trim()) : []),
    toAttribute: (v: string[]) => v.join(','),
  },
})
tags: string[] = [];

Use reflect: true sparingly. It is useful when the attribute drives CSS (:host([online])) or accessibility, but every reflected property is a DOM write on every change.

@property vs @state

Both schedule a re-render. @property is the component’s public API and has an attribute by default. @state is internal: no attribute, and by convention nobody outside should set it. Keeping UI state such as “is the menu expanded” in @state stops it from appearing as an attribute in the markup and signals to consumers that it is not part of the contract.

Mutation does not trigger an update

Lit detects changes by comparing the new value to the old one with !==. Mutating an array or object in place does not change its identity:

this.items.push(item);              // no re-render
this.items = [...this.items, item]; // re-render

I confirmed this: after push, the rendered count stayed the same until the array was reassigned. If you must mutate, call this.requestUpdate() afterwards.


The update lifecycle and where to put code

When a reactive property changes, Lit batches the change and runs an update in a microtask:

  1. willUpdate(changedProperties): runs before render(). Compute derived values here. Setting properties in willUpdate does not schedule an additional update.
  2. render(): returns the template. Should be a pure function of the component’s state.
  3. firstUpdated(changedProperties): once, after the first render. One-time DOM work such as focusing an element.
  4. updated(changedProperties): after every render. DOM measurements, calling imperative APIs on rendered elements.

changedProperties is a Map from property name to the previous value:

willUpdate(changed: PropertyValues<this>) {
  if (changed.has('items') || changed.has('filter')) {
    this.visibleItems = this.items.filter((i) => i.label.includes(this.filter));
  }
}

The mistake is to do this in updated() instead. Setting a property there schedules a second update after the first one has finished, so the component renders twice and briefly shows stale derived state. Lit’s dev mode warns about it:

Element x-list scheduled an update (generally because a property was set) after an
update completed, causing a new update to be scheduled. This is inefficient and should
be avoided unless the next update can only be scheduled as a side effect of the
previous update.

If you see this warning, move the assignment into willUpdate. The legitimate exception is when the new value depends on something only measurable after rendering, such as an element’s size.

await el.updateComplete resolves once pending updates have rendered, which is what tests should wait on instead of timeouts.

Async data: do not call fetch from render()

A common example uses the until directive with a promise created inside render():

render() {
  return html`${until(this.fetchData().then(renderData), html`Loading...`)}`; // new request every render
}

Every re-render calls fetchData() again, so any unrelated property change fires another request. Use @lit/task, which reruns only when its arguments change and passes an AbortSignal so the previous run can be cancelled:

import { Task } from '@lit/task';

private user = new Task(this, {
  task: async ([id], { signal }) => {
    const res = await fetch(`/api/users/${id}`, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json() as Promise<User>;
  },
  args: () => [this.userId],
});

render() {
  return this.user.render({
    pending: () => html`<p>Loading...</p>`,
    complete: (u) => html`<p>${u.name}</p>`,
    error: (e) => html`<p>Could not load user: ${String(e)}</p>`,
  });
}

Styling through the shadow boundary

static styles are scoped to the component’s shadow root. That protects the component from the page’s CSS, and it also means the page cannot style the inside of the component with ordinary selectors. Utility classes from Tailwind or a global stylesheet do not apply inside a shadow root. This is the most common frustration when a team adopts Lit components into an existing app.

What does cross the boundary:

  • Inherited properties such as color, font-family and line-height flow from the host into the shadow tree.

  • CSS custom properties inherit too, which makes them the standard theming API:

    /* inside the component */
    button { background: var(--badge-bg, #1f6feb); }
    /* on the page */
    user-badge { --badge-bg: rebeccapurple; }
  • ::part(): the component marks elements with part="label", and the page can style them with user-badge::part(label) { ... }. Parts give full control over one element but are an explicit, versioned API: renaming a part breaks consumers.

  • Slotted content stays in the light DOM, so the page’s styles apply to it. From inside, ::slotted(selector) can style only the top-level slotted elements, not their descendants.

Two more rules: page styles on the host element (user-badge { display: none }) beat the component’s own :host rules, and :host needs an explicit display because custom elements are display: inline by default.

I have found it much easier to decide the styling API up front (a documented set of custom properties and a few parts) than to add hooks one bug report at a time. A design-system component that exposes nothing forces consumers into wrapper divs and !important hacks, and exposing everything as parts turns internal refactors into breaking changes.


Events, forms and the shadow boundary

Dispatch events from the host element. To let them escape the shadow root and bubble through the page, set both flags:

this.dispatchEvent(new CustomEvent('badge-select', {
  detail: { name: this.name },
  bubbles: true,
  composed: true,
}));

Without composed: true, an event dispatched from inside the shadow root stops at the shadow boundary. Events that do cross it are retargeted: listeners outside see event.target as the host element, not the inner button. Use event.composedPath() if you really need the inner node.

Forms are a harder boundary. An <input> inside a component’s shadow root is not submitted with a surrounding <form> and does not take part in its validation. A Lit input component that looks correct can therefore send nothing. The platform fix is form-associated custom elements:

export class TextField extends LitElement {
  static formAssociated = true;
  private internals = this.attachInternals();
  @property() value = '';

  willUpdate(changed: PropertyValues<this>) {
    if (changed.has('value')) this.internals.setFormValue(this.value);
  }
}

ElementInternals also exposes setValidity() for constraint validation and form for the owning form.


Server rendering and hydration caveats

Custom elements are defined in the browser, so a naive server render outputs empty <user-badge> tags that pop in after JavaScript loads. Lit’s SSR package (@lit-labs/ssr) renders components to HTML with declarative shadow DOM (<template shadowrootmode="open">), which current browsers attach without JavaScript. Keep in mind:

  • It is still a Labs package; the API can change between minor versions.
  • To hydrate instead of re-rendering from scratch, the client must load @lit-labs/ssr-client/lit-element-hydrate-support.js before Lit itself.
  • On the server there is no real DOM. Code in the constructor, willUpdate or render must not touch window, document or localStorage; put browser-only work in connectedCallback, firstUpdated or updated, which do not run during server rendering.
  • Framework integrations (Astro, Next.js, Nuxt) differ in how much of this they support, so check the integration’s documentation rather than assuming it hydrates.

For many sites, rendering Lit components only on the client and reserving layout space with CSS is simpler and good enough.


Lists: map or repeat

A plain items.map(...) returning an html template per item is fine for most lists. Lit reuses DOM positions and updates their contents. The repeat directive adds keys, so DOM nodes move with their items when the list is reordered:

import { repeat } from 'lit/directives/repeat.js';

html`<ul>
  ${repeat(this.todos, (t) => t.id, (t) => html`<todo-item .todo=${t}></todo-item>`)}
</ul>`;

Use repeat when list items hold state in the DOM (focus, an <input>’s typed text, a child component’s internal state) and the list can be reordered or have items removed from the middle. Without keys, deleting the first row can leave the second row’s typed text in what is now the first row’s input. For a static list of text, map is cheaper.


Using Lit components from React

React 19 supports custom elements properly: it sets props as properties when the element defines them and can attach listeners for custom events. React 18 and earlier set every prop as an attribute (so objects arrive as "[object Object]") and do not listen to custom events declaratively.

For React 18, or to get typed props in any version, wrap the element with @lit/react:

import * as React from 'react';
import { createComponent } from '@lit/react';
import { UserBadge } from './user-badge.js';

export const UserBadgeReact = createComponent({
  tagName: 'user-badge',
  elementClass: UserBadge,
  react: React,
  events: { onBadgeSelect: 'badge-select' },
});

In a React-only application, weigh whether the component needs to be a custom element at all. The React internals article explains how React reconciles its own components, which custom elements sit outside of.


When Lit is the right tool

Lit fits when the output must be framework-neutral: a design system consumed by teams using different frameworks, widgets embedded into pages you do not control, or micro-frontends that should not share a framework version. It also fits long-lived components, since the public API is the custom element standard rather than a framework’s component model.

For a single application built with one framework, a Lit layer adds a styling boundary, a second reactivity model and interop work for little gain. Use the framework’s components there, and pull in Lit components where they are shared. Developing the components in isolation, for example with Storybook, which supports web components, helps keep that shared layer independent of any one app.


External references: Lit documentation, Lit dev-mode messages