Decorators & Patterns
Everything is declarative. TypeScript decorators for clean, maintainable components.
@element
Register a custom element. Extends HTMLElement with reactive properties and lifecycle.
@element('my-card')
class MyCard extends HTMLElement {
@property() title = '';
@property({ type: Number }) count = 0;
@render()
template() {
return html`<h1>${this.title}</h1>`;
}
}
@page
Routable page component with route parameters, guards, and lifecycle hooks.
// Guard receives context + route params
const auth: Guard<AppContext> = (ctx, params) => ctx.isAuthenticated();
@page({ tag: 'user-page', routes: ['/users/:id'], guards: [auth] })
class UserPage extends HTMLElement {
// Route param :id auto-binds to this property
@property() id = '';
@property() user = null;
@ready()
async load() {
// this.id is already set from URL
this.user = await fetch(`/api/users/${this.id}`).then(r => r.json());
}
}
@controller
Swappable behavior that attaches to elements. Switch implementations at runtime.
@controller('rest')
class RestLoader {
async attach(el) {
el.data = await fetch('/api/items').then(r => r.json());
}
}
@controller('websocket')
class WsLoader {
attach(el) {
this.ws = new WebSocket('/ws');
this.ws.onmessage = e => el.data = JSON.parse(e.data);
}
detach() { this.ws.close(); }
}
// <my-list controller="rest"> or <my-list controller="websocket">
@property & @watch
Reactive properties that trigger re-renders. Watch for changes.
@property() name = '';
@property({ type: Number }) count = 0;
@property({ type: Boolean, reflect: true }) active = false;
@watch('count')
onCountChange(newVal, oldVal) {
console.log(`Count: ${oldVal} → ${newVal}`);
}
@render & @styles
Template method with differential rendering. Scoped CSS styles.
@render()
template() {
return html`
<div class="card">
<h3>${this.title}</h3>
<if ${this.expanded}>
<p>${this.content}</p>
</if>
</div>
`;
}
@styles()
componentStyles() {
return css`
:host { display: block; }
.card { padding: 1rem; border-radius: 8px; }
`;
}
@ready & @dispose
Lifecycle hooks for initialization and cleanup.
@ready()
onMount() {
this.interval = setInterval(() => this.tick(), 1000);
console.log('Component connected');
}
@dispose()
cleanup() {
clearInterval(this.interval);
this.subscription?.unsubscribe();
}
@query & @queryAll
DOM element references within shadow DOM.
@query('input') inputEl;
@queryAll('.item') items;
@ready()
setup() {
this.inputEl.focus();
console.log(`Found ${this.items.length} items`);
}
@on & @dispatch
Event delegation and custom event emission.
// @on: delegated event listener
@on('click', 'button.save')
handleSave(e) { this.save(); }
// @dispatch: return value becomes event.detail
@dispatch('status-changed')
updateStatus(status) {
this.status = status;
return { status };
}
// Stacked: DOM event triggers custom event
@on('click', '.item')
@dispatch('item-selected')
handleItemClick(e) {
return { id: e.target.dataset.id };
}
@context
Receive router navigation context updates.
// Method receives Context on route changes
@context()
handleContext(ctx: Context) {
this.user = ctx.application.user;
this.route = ctx.navigation.route;
}
@request & @respond
Async generator pattern for parent-child communication.
// Requester: async generator yields payload, awaits response
@request('fetch-user')
async *fetchUser(id: string) {
const user = await (yield { id });
return user;
}
// Responder: receives payload, returns response
@respond('fetch-user')
async handleFetchUser({ id }) {
return await fetch(`/api/users/${id}`).then(r => r.json());
}
// Usage: const user = await this.fetchUser('123');
Template Syntax
Property binding, conditionals, loops, and event shortcuts.
// Property binding
html`<input .value=${this.text}>`
// Conditionals
html`<if ${this.loading}><snice-spinner></snice-spinner></if>`
html`<if ${this.error}><span>${this.error}</span></if>`
// Loops (use .map())
html`${this.items.map(item => html`<li key=${item.id}>${item.name}</li>`)}`
// Keyboard shortcuts
html`<input @keydown:ctrl+s=${this.save}>`
html`<div @keydown:escape=${this.close}>`