Angelitedocs ENES

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:

FunctionWhat it isHow it is used
signal(value)A value you can changes() 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 changestotal() (read only)
effect(fn)Code that runs now and every time the signals it reads changeReturns 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.

Counter with a conditional classlive
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);
Updates are batched

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.

Changing a plain property does not repaint

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.

SyntaxWhat 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.
#nameReference 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 practical rule

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.

To-do list: @for, @if, @empty, computedlive
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.

Always use track with a stable key

track 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.

A custom pipelive
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.

TriggerIt loads…
on idle (default)when the browser is idle
on viewportwhen the placeholder enters the screen
on interaction · on hoveron a click or key press on the placeholder · on mouse over or focus
on timer(2s) · on immediateafter some time · as soon as the rest is painted
when exprthe 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.

@defer: by interaction and by timelive
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);
Separate code, not just separate render

@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).