Reactive properties are the mechanism that connects your component's data to its rendered output. This lesson explains how they're declared, how change detection works, and when updates are scheduled.
What Makes a Property 'Reactive'
A plain JavaScript class field does nothing special when it changes. A *reactive* property, declared via @property()/@state() or the static properties object, is different: Lit wraps it with a getter/setter pair so that any assignment automatically schedules a re-render.
Under the hood, Lit stores the actual value in a private field and generates an accessor on the prototype. When you write this.count = 5, that setter compares the new value to the old one (using !== by default) and, if different, queues an update for the next microtask.
import { LitElement, html } from 'lit';
import { property } from 'lit/decorators.js';
class VoteCounter extends LitElement {
@property({ type: Number }) votes = 0;
render() {
return html`<button @click=${() => this.votes++}>${this.votes} votes</button>`;
}
}
Every click assigns a new value to votes, which triggers Lit's generated setter and schedules a re-render automatically.
@property() declares a public, attribute-reflecting reactive property (part of the component's external API).
@state() declares an internal reactive property with no attribute — for implementation-detail state.
Both compile down to the same underlying static properties metadata Lit reads at class-definition time.
Any field not declared this way is just a normal JS field — assigning it will not trigger a re-render.
Reactive Properties Cheat Sheet
The core options every property declaration supports.
Option
Default
Purpose
type
String
Converts between attribute string and JS value
attribute
true
Whether an HTML attribute is created/observed
reflect
false
Whether property changes write back to the attribute
state
false (via @state)
Marks a property as internal (no attribute at all)
hasChanged
!== comparison
Custom function deciding if a change should trigger an update
noAccessor
false
Skip generating the getter/setter (rare, advanced use)
How Change Detection Works
By default, Lit compares the old and new value of a property using !==. For primitives (strings, numbers, booleans) this works perfectly. For objects and arrays, !== compares references, not contents — mutating an array in place (this.items.push(x)) will not be detected as a change, because the reference didn't change.
// Won't trigger a re-render — same array reference
this.items.push(newItem);
// Will trigger a re-render — new array reference
this.items = [...this.items, newItem];
Always create a new array/object reference when updating collection-like reactive properties.
Customizing Change Detection with hasChanged
For cases where the default reference comparison isn't right (deep equality for a small object, or intentionally ignoring certain changes), pass a hasChanged function in the property options.
@property({
hasChanged(newVal, oldVal) {
return newVal?.id !== oldVal?.id; // only re-render if the id differs
},
})
selectedUser;
Common Mistakes
Mutating an array or object property in place and expecting a re-render, instead of assigning a new reference.
Declaring a field as a plain class property without @property()/@state() and being confused when it doesn't update the UI.
Overusing @property() for values that are purely internal — prefer @state() so they don't pollute the component's public attribute API.
Writing a hasChanged function that always returns true, causing unnecessary re-renders on every assignment.
Key Takeaways
Reactive properties are plain fields wrapped with a generated getter/setter that schedules updates on change.
Change detection defaults to !==, so mutating objects/arrays in place is not detected — replace the reference instead.
@property() is for public, attribute-backed API; @state() is for internal-only reactive fields.
A custom hasChanged function lets you fine-tune exactly when a property change should trigger a re-render.
Pro Tip
When a component 'isn't updating' despite your code clearly changing a value, check for in-place mutation first (.push, .sort, direct property assignment on a nested object) — it's the single most common reactive-properties bug in Lit.
You now understand how reactive properties trigger updates. Next, learn exactly how properties and HTML attributes relate to each other.