Skip to content

Lit State

Not every reactive field belongs in your component's public API. This lesson covers @state(), the decorator for internal-only reactive properties.

What @state() Is For

@state() declares a reactive property exactly like @property(), with one key difference: it never creates or observes a corresponding HTML attribute, and it's conventionally treated as private implementation detail rather than public API. Under the hood, @state() is really just @property({ state: true }).

Use @state() for things like 'is the dropdown currently open', 'has the async fetch completed', or 'which tab is selected internally' — reactive values a consumer of your component should never need to set directly from outside.

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

@customElement('search-box')
export class SearchBox extends LitElement {
  @property() placeholder = 'Search…'; // public API
  @state() private results = [];        // internal only
  @state() private isLoading = false;   // internal only

  render() {
    return html`
      <input placeholder=${this.placeholder} @input=${this.#onInput} />
      ${this.isLoading ? html`<p>Loading…</p>` : html`<result-list .items=${this.results}></result-list>`}
    `;
  }
}

placeholder is intentional public API; results and isLoading are private implementation detail marked with TypeScript's private keyword and @state().

@state() vs @property()

@property() label = '';      // public, attribute-backed, reflects if configured
@state() private busy = false; // internal, no attribute, always private-by-convention
  • @state() never generates an attribute, regardless of type or reflect settings.
  • Pairing @state() with TypeScript's private keyword communicates intent clearly, though it's not enforced at runtime.
  • Both @property() and @state() trigger the same reactive update cycle — the difference is purely about attribute exposure and API intent.
  • static properties = { busy: { state: true } } is the plain JavaScript equivalent for projects without decorators.

@property() vs @state() Cheat Sheet

Choosing the right decorator for a given reactive field.

Aspect @property() @state()
HTML attribute Yes, by default Never
Intended audience External consumers Internal implementation only
Typical naming Public field Often marked private in TypeScript
Triggers re-render on change Yes Yes
Good for disabled, variant, value isOpen, loadingState, internalCache

Using @state() for Async Operation Results

A very common pattern is fetching data in connectedCallback() and storing the loading/success/error state in one or more @state() fields, so the template can react automatically as the fetch progresses.

@state() private status: 'idle' | 'loading' | 'success' | 'error' = 'idle';
@state() private data: unknown[] = [];

async connectedCallback() {
  super.connectedCallback();
  this.status = 'loading';
  try {
    this.data = await fetchResults();
    this.status = 'success';
  } catch {
    this.status = 'error';
  }
}

Each assignment to status or data schedules a re-render, so the template stays in sync automatically as the fetch resolves.

Why 'Private' Is a Convention, Not Enforcement

TypeScript's private keyword prevents *other TypeScript code* from accessing a field at compile time, but it does not hide the field at runtime — any JavaScript code (or DevTools) can still read or set it. For fields that must truly be inaccessible from outside, use native private class fields (#count) instead, though those cannot be declared reactive with @state() directly in current Lit versions without additional care.

  • TypeScript private is a compile-time-only convention useful for internal @state() fields.
  • Native #private fields are runtime-enforced but aren't directly usable as decorated reactive properties.
  • For most components, TypeScript private plus @state() is a perfectly reasonable and common convention.

Common Mistakes

  • Using @property() for values that are purely internal, unnecessarily growing the component's public attribute surface.
  • Assuming TypeScript's private keyword prevents runtime access to a @state() field.
  • Forgetting that @state() fields still trigger re-renders exactly like @property() fields — 'internal' does not mean 'non-reactive'.
  • Storing derived values (that could be computed in render()) as separate @state() fields, adding unnecessary synchronization to keep in sync.

Key Takeaways

  • @state() declares a reactive property with no corresponding HTML attribute, intended for internal use only.
  • Both @property() and @state() trigger the same underlying reactive update cycle.
  • Use @state() for loading flags, fetched data, and other implementation-detail state.
  • TypeScript's private is a compile-time convention, not a runtime access restriction.

Pro Tip

As a rule of thumb: if you'd be comfortable documenting a property in your component's public README as something consumers can set, use @property(). If you wouldn't, use @state() — the decision should track your actual API design intent.