Angelitedocs ESEN

Inicio › Aprender

Señales y plantillas

Estado reactivo con señales y plantillas al estilo de Angular: interpolación, enlaces, @if, @for, pipes y @defer.

Señales#

El estado de una app AngeLite vive en señales: contenedores de un valor que avisan a quien los lee cuando cambian. Hay tres piezas:

FunciónQué esCómo se usa
signal(valor)Un valor que puedes cambiars() lee, s.set(x) escribe, s.update(v => …) transforma, s.peek() lee sin suscribirse
computed(fn)Un valor derivado: se calcula al leerlo y se recalcula solo cuando cambia algo de lo que leetotal() (solo lectura)
effect(fn)Código que se ejecuta ahora y cada vez que cambian las señales que leeDevuelve una función para detenerlo; fn puede devolver una función de limpieza

La clave es lo que pasa en una plantilla: al leer count() en una interpolación, esa parte concreta del DOM —un nodo de texto, un atributo, un bloque— se suscribe a count. Cuando cambia, AngeLite vuelve a evaluar solo esas expresiones y escribe en el DOM solo si el valor es distinto. No hay árbol virtual, ni Zone.js, ni un ciclo de detección de cambios que recorra la app.

Contador con clase condicionalen vivo
Ver el código
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() }} · doble = {{ double() }}
    </p>`
});

mount(Counter, root);
Las actualizaciones se agrupan

Varios set() seguidos provocan un único repintado: los cambios se aplican en un microtask. Por eso, justo después de un set() el DOM todavía no refleja el cambio; si necesitas esperar, await Promise.resolve() (o flush() en los tests) basta.

Cambiar una propiedad normal no repinta

this.nombre = 'Ada' no avisa a nadie. Lo que quieres que reaccione tiene que ser una señal: nombre = signal('Ada') y this.nombre.set('Ada'). Si cambias un objeto o un array, pon uno nuevo: items.update(l => [...l, nuevo]).

Plantillas#

Una plantilla es HTML con unas pocas extensiones. Se compila una sola vez a una lista de instrucciones («este nodo de texto depende de esta expresión», «este elemento tiene este evento»); después, crear una vista es clonar el HTML y aplicar las instrucciones.

SintaxisQué hace
{{ expr }}Interpolación. Siempre texto: nunca se interpreta como HTML.
[prop]="expr"Enlaza una propiedad del DOM, o un input() si el elemento es un componente.
[class.x]="cond" · [style.width.px]="n" · [attr.x]="v"Una clase, un estilo (con unidad opcional) o un atributo. Con null o false el atributo se quita.
(evento)="sentencia"Escucha un evento. $event es el evento (o el valor emitido por un output). Varias sentencias con ;.
[(ngModel)]="sig"Enlace bidireccional con una señal escribible.
{{ x | pipe:arg }}Pipes, encadenables.
#nombreReferencia al elemento, que recibes con viewChild('nombre') (ver Componentes).

Qué se puede escribir en una expresión#

Las expresiones las ejecuta un intérprete propio, sin eval ni new Function. Soporta literales, operadores aritméticos, de comparación y lógicos, ternario (a ? b : c), ??, acceso con ., ?. y [ ], arrays, y llamadas a métodos del componente. No soporta funciones flecha, literales de objeto, asignaciones, new ni plantillas con comillas invertidas.

En lugar de…escribe…
(click)="x = 1"(click)="x.set(1)"
(click)="n++"un método en la clase: (click)="inc()"
[style]="{ color: c }"[style.color]="c"
items.filter(x => x.ok)un computed o un método: okItems()
La regla práctica

La plantilla decide qué se ve; la lógica va en la clase (métodos y computed). Si una expresión necesita más de un operador, ponle nombre en la clase.

Control de flujo#

Los bloques @if, @else if, @else, @for y @empty usan la misma sintaxis que Angular 17+. @for reutiliza los nodos del DOM según la clave de track, así que reordenar o actualizar una lista no recrea los elementos que siguen existiendo: solo se mueven.

Lista de tareas: @for, @if, @empty, computeden vivo
Ver el código
const { signal, computed, component, mount } = AngeLite;

class Todos {
  draft = signal('');
  items = signal([{ id: 1, text: 'Probar 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="Nueva tarea">
    <button (click)="add()">Añadir</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 hay tareas 🎉</li>
      }
    </ul>

    @if (pending() > 0) {
      <p>Te quedan {{ pending() }} por hacer.</p>
    } @else {
      <p>¡Todo hecho!</p>
    }`
});

mount(Todos, root);

Variables disponibles dentro de @for: $index, $count, $first, $last, $even y $odd.

Usa siempre track con una clave estable

track t.id le dice a AngeLite qué fila es cuál. Sin track la clave es el propio elemento (vale para textos y números, no para objetos que se recrean). Con claves repetidas, enableDevWarnings() te avisa.

No hay @switch: encadena @if (…) {…} @else if (…) {…} @else {…}.

Pipes#

Un pipe transforma un valor dentro de la plantilla: {{ precio | currency:'EUR' }}. Incluidos: uppercase, lowercase, titlecase, json, number, percent, currency, slice y date. El bundle completo los trae; con el runtime solo (plantillas precompiladas) importa @duvanjamid/angelite/pipes o registra los tuyos.

Pipe propioen vivo
Ver el código
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);

Un pipe es solo una función (valor, ...argumentos) => resultado. Si lee señales dentro de una plantilla, la vista se actualiza cuando cambian (así funcionan los pipes de i18n).

Carga diferida con @defer#

@defer no renderiza el bloque hasta que se cumple un disparador, y mientras tanto muestra el @placeholder. Sirve para no pagar el coste de lo que el usuario aún no ve.

DisparadorSe carga…
on idle (por defecto)cuando el navegador está libre
on viewportcuando el placeholder entra en pantalla
on interaction · on hoveral hacer clic o pulsar una tecla en el placeholder · al pasar el ratón o enfocarlo
on timer(2s) · on immediatetras un tiempo · en cuanto se pinta lo demás
when exprla primera vez que la expresión es verdadera (después no se vuelve atrás)

Se pueden combinar con ;: @defer (on viewport; on timer(5s)) carga con lo que ocurra primero. viewport, interaction y hover necesitan un elemento en el @placeholder.

@defer: por interacción y por tiempoen vivo
Ver el código
const { signal, component, mount } = AngeLite;

class App {
  listo = signal(false);
}
component(App, {
  template: `
    @defer (on interaction) {
      <p>Cargado al hacer clic.</p>
    } @placeholder {
      <button>Haz clic para cargar</button>
    }
    @defer (on timer(1500ms)) {
      <p>Apareció tras 1,5 s.</p>
    } @placeholder {
      <p><i>Esperando…</i></p>
    }
    <label><input type="checkbox" [(ngModel)]="listo"> marcar para cargar</label>
    @defer (when listo()) {
      <p>Cargado porque <code>listo()</code> es verdadero.</p>
    }`
});

mount(App, root);
Código aparte, no solo render aparte

@defer retrasa el render. Para dividir el código de una pantalla entera en otro archivo, usa load: () => import('./pantalla.js') en una ruta. En una app precompilada, @defer necesita el bundle runtime-plus (el paquete principal ya lo incluye; enableDevWarnings() avisa si falta).