LumenJS pages are Lit components. Declare reactive properties to trigger re-renders when state changes. You can use either static properties or the @property() decorator. Both work out of the box.
Two equivalent ways to declare reactive properties:
Plain JavaScript. No decorator config needed:
// pages/counter.ts import { LitElement, html } from 'lit'; export class PageCounter extends LitElement { static properties = { count: { type: Number }, }; count = 0; render() { return html` <p>Count: ${this.count}</p> <button @click=${() => this.count++}>+1</button> `; } }
Cleaner syntax. Import from lit/decorators.js:
// pages/counter.ts import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; export class PageCounter extends LitElement { @property({ type: Number }) count = 0; render() { return html` <p>Count: ${this.count}</p> <button @click=${() => this.count++}>+1</button> `; } }
The type option tells Lit how to convert HTML attributes to property values:
| Type | Attribute value | Property value |
|---|---|---|
String | "hello" | 'hello' |
Number | "42" | 42 |
Boolean | "" (present) | true |
Object | , | Set via JS only |
Array | , | Set via JS only |
static properties = { name: { type: String }, // reflects from/to attributes count: { type: Number }, active: { type: Boolean }, data: { type: Object }, // no attribute - set via JS items: { type: Array }, };
For properties that should not be exposed as attributes, use state: true (or the @state() decorator). These are private reactive state. They trigger re-renders but are not visible in the DOM.
import { LitElement, html } from 'lit'; import { property, state } from 'lit/decorators.js'; export class PageSearch extends LitElement { @property({ type: String }) query = ''; @state() _results = []; @state() _loading = false; async _search() { this._loading = true; const res = await fetch(`/api/search?q=${this.query}`); this._results = await res.json(); this._loading = false; } render() { return html` <input .value=${this.query} @input=${(e) => this.query = e.target.value}> <button @click=${this._search}>Search</button> ${this._loading ? html`<p>Loading...</p>` : this._results.map(r => html`<p>${r.title}</p>`)} `; } }
Dynamic route parameters are set as attributes on the component automatically. Declare them as properties to use them reactively:
// pages/blog/[slug].ts import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; export class PageBlogSlug extends LitElement { @property() slug = ''; render() { return html`<h1>Post: ${this.slug}</h1>`; } }
Each key returned by a server loader() is automatically spread as an individual property on the component. Declare a matching @property() for each key:
// pages/dashboard.ts import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; export async function loader() { return { users: await db.users.findAll(), title: 'Dashboard' }; } export class PageDashboard extends LitElement { @property({ type: Array }) users = []; @property({ type: String }) title = ''; render() { return html` <h1>${this.title}</h1> <p>Users: ${this.users?.length}</p> `; } }
When using subscribe(), each key from push() is spread as an individual property — same pattern as loader data:
// pages/feed.ts import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; export function subscribe({ push }) { const id = setInterval(() => { push({ time: Date.now() }); }, 1000); return () => clearInterval(id); } export class PageFeed extends LitElement { @property({ type: Number }) time = 0; render() { return html`<p>Server time: ${this.time}</p>`; } }
Use Lit lifecycle methods for side effects. connectedCallback() runs when the component is added to the DOM, disconnectedCallback() when removed:
import { LitElement, html } from 'lit'; import { state } from 'lit/decorators.js'; export class PageClock extends LitElement { @state() _time = new Date(); _interval; connectedCallback() { super.connectedCallback(); this._interval = setInterval(() => { this._time = new Date(); }, 1000); } disconnectedCallback() { clearInterval(this._interval); super.disconnectedCallback(); } render() { return html`<p>${this._time.toLocaleTimeString()}</p>`; } }
loader() for fetching data on the server and subscribe() for real-time updates. Only use connectedCallback() for client-side-only concerns like timers, DOM observers, or third-party library init.
| Pattern | Decorator | Static properties | Use case |
|---|---|---|---|
| Public property | @property() | { type: String } | Attributes, route params |
| Internal state | @state() | { state: true } | UI state (loading, etc.) |
| Loader data (object) | @property({ type: Object }) | { type: Object } | All server data as one object |
| Loader data (individual) | @property({ type: Array }) | { type: Array } | Each loader key as its own property |
| Live data | @property({ type: Object }) | { type: Object } | Real-time via subscribe() |