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() 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.
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.
You now understand internal reactive state. Next, explore the full range of property options available to you.