Home › Learn
Signals and templates
Reactive state with signals and Angular-style templates: interpolation, bindings, @if, @for, pipes and @defer.
Signals#
The state of an AngeLite app lives in signals: containers of a value that notify whoever reads them when they change. There are three pieces:
| Function | What it is | How it is used |
|---|---|---|
signal(value) | A value you can change | s() reads, s.set(x) writes, s.update(v => …) transforms, s.peek() reads without subscribing |
computed(fn) | A derived value: calculated when read and recalculated only when something it reads changes | total() (read only) |
effect(fn) | Code that runs now and every time the signals it reads change | Returns a function to stop it; fn can return a cleanup function |
The key is what happens in a template: when you read count() in an interpolation, that specific part of the DOM —a text node, an attribute, a block— subscribes to count. When it changes, AngeLite re-evaluates only those expressions and writes to the DOM only if the value is different. There is no virtual tree, no Zone.js, and no change-detection cycle walking the app.
View the code
const { signal, computed, component, mount } = AngeLite;
class Counter {
count = signal(0);
double = computed(() => this.count() * 2);
inc() { this.count.update(n => n + 1); }
reset() { this.count.set(0); }
}
component(Counter, {
template: `
<button (click)="inc()">+1</button>
<button (click)="reset()">reset</button>
<p [class.big]="count() >= 5">
count = {{ count() }} · double = {{ double() }}
</p>`
});
mount(Counter, root);Several set() calls in a row cause a single repaint: changes are applied in a microtask. That is why, right after a set(), the DOM does not reflect the change yet; if you need to wait, await Promise.resolve() (or flush() in tests) is enough.
this.name = 'Ada' notifies nobody. Whatever you want to react has to be a signal: name = signal('Ada') and this.name.set('Ada'). If you change an object or an array, put in a new one: items.update(l => [...l, next]).
Templates#
A template is HTML with a few extensions. It is compiled once into a list of instructions ("this text node depends on this expression", "this element has this event"); after that, creating a view is cloning the HTML and applying the instructions.
| Syntax | What it does |
|---|---|
{{ expr }} | Interpolation. Always text: it is never interpreted as HTML. |
[prop]="expr" | Binds a DOM property, or an input() if the element is a component. |
[class.x]="cond" · [style.width.px]="n" · [attr.x]="v" | A class, a style (with an optional unit) or an attribute. With null or false the attribute is removed. |
(event)="statement" | Listens to an event. $event is the event (or the value emitted by an output). Several statements with ;. |
[(ngModel)]="sig" | Two-way binding with a writable signal. |
{{ x | pipe:arg }} | Pipes, chainable. |
#name | Reference to the element, which you receive with viewChild('name') (see Components). |
What you can write in an expression#
Expressions are run by an interpreter of our own, with no eval or new Function. It supports literals, arithmetic, comparison and logical operators, the ternary (a ? b : c), ??, access with ., ?. and [ ], arrays, and calls to component methods. It does not support arrow functions, object literals, assignments, new or backtick templates.
| Instead of… | write… |
|---|---|
(click)="x = 1" | (click)="x.set(1)" |
(click)="n++" | a method in the class: (click)="inc()" |
[style]="{ color: c }" | [style.color]="c" |
items.filter(x => x.ok) | a computed or a method: okItems() |
The template decides what is shown; logic goes in the class (methods and computed). If an expression needs more than one operator, give it a name in the class.
Control flow#
The @if, @else if, @else, @for and @empty blocks use the same syntax as Angular 17+. @for reuses DOM nodes according to the track key, so reordering or updating a list does not recreate the elements that still exist: they are only moved.
View the code
const { signal, computed, component, mount } = AngeLite;
class Todos {
draft = signal('');
items = signal([{ id: 1, text: 'Try AngeLite', done: true }]);
pending = computed(() => this.items().filter(t => !t.done).length);
next = 2;
add() {
const text = this.draft().trim();
if (!text) return;
this.items.update(l => [...l, { id: this.next++, text, done: false }]);
this.draft.set('');
}
toggle(id) {
this.items.update(l => l.map(t => t.id === id ? { ...t, done: !t.done } : t));
}
remove(id) { this.items.update(l => l.filter(t => t.id !== id)); }
}
component(Todos, {
template: `
<input [(ngModel)]="draft" placeholder="New task">
<button (click)="add()">Add</button>
<ul>
@for (t of items(); track t.id) {
<li>
<input type="checkbox" [checked]="t.done" (change)="toggle(t.id)">
<span [style.text-decoration]="t.done ? 'line-through' : ''">{{ $index + 1 }}. {{ t.text }}</span>
<button (click)="remove(t.id)">×</button>
</li>
} @empty {
<li>No tasks 🎉</li>
}
</ul>
@if (pending() > 0) {
<p>You have {{ pending() }} left to do.</p>
} @else {
<p>All done!</p>
}`
});
mount(Todos, root);Variables available inside @for: $index, $count, $first, $last, $even and $odd.
track with a stable keytrack t.id tells AngeLite which row is which. Without track the key is the item itself (fine for strings and numbers, not for objects that get recreated). With repeated keys, enableDevWarnings() warns you.
There is no @switch: chain @if (…) {…} @else if (…) {…} @else {…}.
Pipes#
A pipe transforms a value inside the template: {{ price | currency:'EUR' }}. Included: uppercase, lowercase, titlecase, json, number, percent, currency, slice and date. The full bundle ships them; with the runtime alone (precompiled templates) import @duvanjamid/angelite/pipes or register your own.
View the code
const { signal, component, mount, registerPipe } = AngeLite;
registerPipe('reverse', s => String(s).split('').reverse().join(''));
registerPipe('repeat', (s, n) => String(s).repeat(n));
class P { word = signal('angelite'); }
component(P, {
template: `<p>{{ word() | reverse }} · {{ word() | slice:0:3 | repeat:3 }}</p>`
});
mount(P, root);A pipe is just a function (value, ...arguments) => result. If it reads signals inside a template, the view updates when they change (that is how the i18n pipes work).
Lazy loading with @defer#
@defer does not render the block until a trigger is met, and meanwhile shows the @placeholder. It is for not paying the cost of what the user cannot see yet.
| Trigger | It loads… |
|---|---|
on idle (default) | when the browser is idle |
on viewport | when the placeholder enters the screen |
on interaction · on hover | on a click or key press on the placeholder · on mouse over or focus |
on timer(2s) · on immediate | after some time · as soon as the rest is painted |
when expr | the first time the expression is true (it does not go back afterwards) |
They can be combined with ;: @defer (on viewport; on timer(5s)) loads on whichever happens first. viewport, interaction and hover need an element in the @placeholder.
View the code
const { signal, component, mount } = AngeLite;
class App {
ready = signal(false);
}
component(App, {
template: `
@defer (on interaction) {
<p>Loaded on click.</p>
} @placeholder {
<button>Click to load</button>
}
@defer (on timer(1500ms)) {
<p>Appeared after 1.5 s.</p>
} @placeholder {
<p><i>Waiting…</i></p>
}
<label><input type="checkbox" [(ngModel)]="ready"> check to load</label>
@defer (when ready()) {
<p>Loaded because <code>ready()</code> is true.</p>
}`
});
mount(App, root);@defer delays the render. To split the code of a whole screen into another file, use load: () => import('./screen.js') in a route. In a precompiled app, @defer needs the runtime-plus bundle (the main package already includes it; enableDevWarnings() warns if it is missing).