Events
Elements in a spec can define event handlers via the on property. When a rendered component calls its emit function, the library looks up the corresponding action binding and dispatches it to a registered handler.
How Events Work
The event flow has three parts:
- Element definition -- the
onproperty maps event names to action bindings - Component -- calls
emit('eventName')when the user interacts - Handler -- a registered function that executes the action
Component calls emit('submit')
--> Library looks up on.submit
--> Finds { action: 'handleSubmit', params: { formId: 'login' } }
--> Calls handlers['handleSubmit']({ formId: 'login' })
Defining Event Handlers in a Spec
The on property on a UIElement maps event names to action bindings:
{
type: 'Button',
props: { label: 'Submit' },
on: {
click: { action: 'handleSubmit', params: { formId: 'login' } },
},
}Each binding has:
| Property | Type | Description |
|---|---|---|
action | string | The key used to look up the handler function |
params | Record<string, unknown> | Parameters passed to the handler |
Multiple Handlers per Event
An event can trigger multiple actions by using an array:
on: {
click: [
{ action: 'trackAnalytics', params: { event: 'button_click' } },
{ action: 'handleSubmit', params: { formId: 'login' } },
],
}Both handlers are called in order when the component emits click.
The Emit Function
Every rendered component receives an emit input -- a function with the signature (event: string) => void. Call it from your component to dispatch an event:
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
@Component({
selector: 'app-button',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button (click)="onClick()">{{ label() }}</button>
`,
})
export class ButtonComponent {
readonly label = input<string>('');
readonly emit = input<(event: string) => void>(() => {});
readonly childKeys = input<string[]>([]);
readonly spec = input<unknown>(null);
onClick() {
this.emit()('click');
}
}Because emit is declared with input(), it is a Signal. Call this.emit() to get the function, then invoke it with the event name: this.emit()('click').
Registering Handlers
Handlers are plain functions registered either globally via provideRender() or per-instance on <render-spec>:
@Component({
selector: 'app-root',
standalone: true,
imports: [RenderSpecComponent],
template: `
<render-spec
[spec]="spec"
[registry]="registry"
[store]="store"
[handlers]="handlers"
/>
`,
})
export class AppComponent {
store = signalStateStore({ submitted: false });
handlers = {
handleSubmit: (params: Record<string, unknown>) => {
console.log('Form submitted:', params['formId']);
this.store.set('/submitted', true);
},
trackAnalytics: (params: Record<string, unknown>) => {
console.log('Analytics event:', params['event']);
},
};
}Handler Signature
Each handler receives a params object and can return a value or a Promise:
type Handler = (params: Record<string, unknown>) => unknown | Promise<unknown>;Injection Context
Handlers execute inside Angular's runInInjectionContext. This means you can call inject() to access services:
const handlers = {
saveForm: async (params: Record<string, unknown>) => {
const http = inject(HttpClient);
const snapshot = store.getSnapshot();
await firstValueFrom(http.post('/api/forms', snapshot));
store.set('/saved', true);
},
};This works for handlers passed via [handlers] on <render-spec>, provideRender(), or other render-enabled components like ChatComponent (from @threadplane/chat).
Resolution Priority
Handlers resolve with the same priority as other inputs:
handlersinput on<render-spec>(highest priority)handlersinprovideRender()config (fallback)
Action Dispatch Pattern
A common pattern is to use handlers to update the state store in response to user interactions. That creates a unidirectional data flow:
User clicks button
--> emit('click')
--> handler updates store
--> Signals propagate
--> UI re-renders
Here's a complete example:
const spec: Spec = {
root: 'app',
elements: {
app: {
type: 'Container',
props: {},
children: ['counter', 'increment'],
},
counter: {
type: 'Text',
props: { label: { $state: '/count' } },
},
increment: {
type: 'Button',
props: { label: 'Increment' },
on: {
click: { action: 'increment', params: {} },
},
},
},
state: { count: 0 },
};
const store = signalStateStore({ count: 0 });
const handlers = {
increment: () => {
const current = store.get('/count') as number;
store.set('/count', current + 1);
},
};Async Handlers
Handlers can be asynchronous. The library does not await the return value, but you can use async functions for server calls or other asynchronous operations:
const handlers = {
saveForm: async (params: Record<string, unknown>) => {
const snapshot = store.getSnapshot();
await fetch('/api/forms', {
method: 'POST',
body: JSON.stringify(snapshot),
});
store.set('/saved', true);
},
};Observing Render Events
Beyond dispatching handlers, <render-spec> emits a single stream of every notable thing that happens during rendering through its events output. Bind to it to observe handler dispatch, state changes, and mount/destroy lifecycle in one place:
<render-spec
[spec]="spec"
[registry]="registry"
[store]="store"
[handlers]="handlers"
(events)="onEvent($event)"
/>import type { RenderEvent } from '@threadplane/render';
onEvent(event: RenderEvent) {
switch (event.type) {
case 'handler':
console.log('handler ran:', event.action, event.params, event.result);
break;
case 'stateChange':
console.log('state changed:', event.path, '=', event.value);
break;
case 'lifecycle':
console.log('lifecycle:', event.event, event.scope, event.elementType);
break;
case 'result':
console.log('component result:', event.elementKey, event.value);
break;
}
}RenderEvent is a discriminated union of four variants, keyed by type:
type | Interface | Fires when | Notable fields |
|---|---|---|---|
'handler' | RenderHandlerEvent | A handler finishes running | action, params, result? |
'stateChange' | RenderStateChangeEvent | The store value changes | path, value, snapshot |
'lifecycle' | RenderLifecycleEvent | A spec or element mounts/destroys | event ('mounted' | 'destroyed'), scope ('spec' | 'element'), elementKey?, elementType? |
'result' | RenderResultEvent | A mounted view component calls injectRenderHost().result(value) | value, elementKey? |
All four interfaces are exported from @threadplane/render. This output is the single source the Lifecycle guide builds its RENDER_LIFECYCLE signals on top of -- both observe the same stream, so there's no double-counting.