Señales (Signals)

<< Plantillas Filtros (Pipes) >>

Hasta la versión 19 de Angular, había una dependencia obligatoria llamada Zone.js. Esta librería actuaba como un observador global de las aplicaciones. Su principal función es capturar y monitorear cualquier actividad asíncrona que ocurra dentro de una aplicación, ya sean eventos del DOM, promesas, timeouts, o cualquier otro tipo de operación que no se ejecute de manera inmediata.

Sin embargo, desde la versión 19, y sobretodo a partir de la 20, la forma recomendada es prescindir de esta librería para mejorar la eficiencia de nuestra aplicación, ya que Angular es capaz de funcionar sin ella para gestionar la detección de cambios en las últimas versiones. Para ello, la aplicación se configura con la opción provideZonelessChangeDetection y no se instala la librería Zone.js.

export const appConfig: ApplicationConfig = {
  providers: [provideZonelessChangeDetection(), /* Resto de providers */]
};

A partir de ahora vamos a asumir que la aplicación creada es zoneless y explicar como detecta Angular los cambios en ese supuesto.

Detección de cambios en componentes

Angular comprueba en cada ciclo de detección de cambios si alguna propiedad de un componente vinculada en la plantilla ha cambiado de estado. Los eventos que pueden desdencadenar una detección de cambios son los siguientes: 

  • Ejecución de un método (síncrono) a partir de un evento de la plantilla
  • Cambio en el valor de una signal vinculada a la plantilla del componente.
  • Cuando un observable vinculado en la plantilla del componente, con el pipe async, emite un nuevo valor

Si se producen cambios en una función asíncrona (Peticiones Http, promesas, temporizadores: setTimeout, setInterval, Otras APIs asíncronas), Angular solo los detectará automáticamente si se producen en un valor de tipo signal.

Por defecto (default)

Cuando se produce un evento en la plantilla (independientemente de que haya cambios o no), o cambia un valor de tipo signal asociado a la plantilla en un componente, el componente se marca como sucio (dirty) para que Angular lo compruebe en el siguiente ciclo de detección de cambios.

Cuando se produce un evento en la plantilla, Angular marca como sucio ese componente y todos su antecesores. Después lanza un ciclo de detección de cambios desde el componente App por todos los componentes de la aplicación. Si detecta cambios, actualiza la plantilla de esos componentes.

Si queremos provocar manualmente que Angular detecte cambios en operaciones asíncronas, podemos marcar el componente como dirty utilizando el método markForCheck del servicio ChangeDetectorRef. Por ejemplo, para detectar que la imagen a previsualizar ha cambiado:

@Component({/*...*/})
export class ProductsPage {
  //...
  #changeDetector = inject(ChangeDetectorRef);
  //...

  changeImage(fileInput: HTMLInputElement) { // Referencia directa al input
    if (!fileInput.files?.length) return;
    const reader = new FileReader();
    reader.readAsDataURL(fileInput.files[0]);
    reader.addEventListener('loadend', () => {
      this.newProduct.imageUrl = reader.result as string; // Cambio asíncrono
      this.#changeDetector.markForCheck(); // Marcamos componente como dirty
    });
  }

  //...
}

Estrategia onPush

La estrategia onPush en una aplicación zoneless, es mucho más eficiente y hace que Angular ejecute la detección de cambios en ese componente de forma mucho más limitada. Básicamente, en la fase de detección de cambios, solo comprueba el componente si ha sido marcado como dirty. En caso contrario, no propaga la detección de cambios hacia abajo (solo se comprobarían componentes internos marcados como dirty).

Es recomendable en una aplicación zoneless que todos los componentes tengan la estrategia onPush para poder beneficiarnos de la mejora de la eficiencia y control de la detección cambios limitada.

@Component({
  //...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductsPage { /* ...*/ }

Para habilitar la estrategia onPush por defecto para todos tus componentes creados a partir de ahora, añade esta configuración al archivo angular.json.

{
  //...
  "projects": {
    "angular-products": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": { // Si no lo tienes, añade esto también
          // Si create un proyecto sin tests ya tendrás "skipTests": true,
          "changeDetection": "OnPush" // Esta es la línea clave
        }, // ...
      },
      //...
    }
  }
}

onPush + signals

En el caso de usar signals y vincularlas en la plantilla del componente, si lo combinamos con la estrategia OnPush, Angular solo marcará el componente actual como dirty, y ejecutará la detección de cambios solo en el componente actual y desde el componente raíz (AppComponent) solo se propagará por aquellos componentes que no tengan la estrategia OnPush. 

Signals API

Una señal es un objeto que almacena un valor, y cada vez que ese valor se modifica, se notifica a aquellas partes del código que utilizan el valor de la señal (plantilla de un componente) o funciones programadas para ejecutarse automáticamente cuando el valor cambia.

Creación de una señal: signal

Para crear una señal llamaremos a la función signal (@angular/core) pasándole el valor inicial. Esto nos devolverá un objeto del tipo WritableSignal, lo que implica que su valor puede modificarse en cualquier momento.

export class ProductsPage {
  //...
  showImage = signal(true); // WritableSignal<boolean>
  //...
}

Acceder al valor de una señal

Las señales funcionan también como getters del valor que almacenan. Esto quiere decir que para acceder a su valor, basta con añadir los paréntesis como si llamaramos a una función. Esto también ocurre en la plantilla del componente.

@let buttonText = (showImage()?'Hide':'Show') + ' images';
<button class="btn btn-sm" [class]="{
    'btn-danger': showImage(),
    'btn-primary': !showImage(),
}" (click)="toggleImage()">
{{ buttonText }}
</button>
<!-- ... -->
<!-- En el td donde mostrábamos la imagen... -->
<td>
    <img    [src]="product.imageUrl" 
            [class.d-none]="!showImage()" 
            [alt]="product.description"
            [title]="product.description">
</td>

Cambiar el valor de una señal

Para cambiar el valor de una señal tenemos 2 métodos. El primero es set, al que le pasaremos directamente el nuevo valor que almacenaremos.

export class ProductsPage {
  //...
  toggleImage() {
    this.showImage.set(!this.showImage());
  }
  //...
}

También podemos utilizar el método update, que permite cambiar el valor mediante una función que recibe el valor actual. Lo que devolvemos en esta función sería el nuevo valor almacenado (similar al método map de los arrays).

export class ProductsPage {
  //...
  toggleImage() {
    this.showImage.update((show) => !show);
  }
  //...
}

En ambos casos, si el valor almacenado es un array u objeto, siempre debemos devolver una nueva referencia para que Angular detecte que el valor ha cambiado. Es decir, un nuevo array u objeto a partir del original.

¿Debo cambiar siempre la referencia?

Si no cambias la referencia a la que apunta la señal, Angular no detecta que el valor de la señal ha cambiado (similar a la estrategia OnPush con los inputs del componente). Esto hará que las señales que dependan de esta (usando computed por ejemplo) no actualicen su valor. 

Para la detección de cambios en la plantilla dependerá del origen del cambio. Como sabemos de la estrategia OnPush o zoneless, si el cambio procede de un evento generado por el componente, Angular detectará que debe comprobar si alguna propiedad vinculada en la plantilla ha cambiado y actualizarla. En caso de que el cambio venga de otra fuente (una operación asíncrona o un evento de una librería), entonces no detectará que hay un cambio si no cambiamos la referencia de la señal en el componente.

Para indicarle a Angular que debe revisar las propiedades vinculadas a la plantilla porque ha habido algun cambio, siempre puedes llamar de forma manual al método ChangeDetectorRef.markForCheck() para indicarle a Angular manualmente que debe marcar el componente como dirty y comprobar los cambios. Ejemplo de cambio a una propiedad interna: producto().rating = 4.

Si quieres salir de dudas, con el componente utilizando la estrategia OnPush (o en una aplicación zoneless), prueba a ver si se detectan los cambios y se actualiza la plantilla sin necesidad de llamar al método sugerido antes. 

¿Cuando usar señales?

Aunque utilicemos la estrategia OnPush o una aplicación Zoneless, en principio no hace falta usar señales para todos los valores, ya que Angular es capaz de detectar cambios en valores normales vinculados a la plantilla, pero solo cuando se producen a partir de un evento. Sin embargo, pueden ser muy útiles en ciertos casos:

  • Cuando el valor se cambia desde una operación asíncrona (evento, observable, etc) y queremos que detecte el cambio. 
  • El punto de arriba es independiente de si la operación se hace en el componente o desde una librería ajena a Angular, ya que las señales no dependen de ZoneJS
  • Cuando el valor se obtiene desde otro lugar (un servicio por ejemplo)
  • Cuando queremos reaccionar al cambio de valor. Esto se hace, por ejemplo, actualizando los valores de una señal a partir de otra (computed, linkedSignal) o ejecutando alguna tarea (effect)
  • Operaciones más avanzadas (utilizando un paradigma de programación más reactivo) combinando las señales con observables mediante las funciones toSignal y toObservable

Los cambios generados a partir de manejadores de eventos ejectuados desde la plantilla o cambios en valores asociados a formularios (ngModel), marcan el componente como dirty para su comprobación, aunque el valor no esté en una señal. En estos casos Angular actualizará la plantilla del componente automáticamente.

Señales computadas: computed

Se pueden crear señales cuyos valores dependen de los valores de otras señales, y que cambian dinámicamente cuando los valores de las señales de las cual dependen cambian a su vez.

Para crear una señal dependiente de otras usamos la función computed. Esta recibe una función por parámetro, y lo que devolvamos ahí será el valor que almacenará la señal computada. Si utilizamos valores de otras señales en esta función, cuando estas cambién, la función se ejecutará otra vez actualizando el valor de la señal dependiente.

export class Ejemplo {
    count = signal(0);
    doubleCount = computed(() => this.count() * 2); // Su valor será siempre el doble que count
}

Las señales generadas a partir de la llamada a computed son de solo lectura. Es decir, están tipadas como Signal en contraposición a WritableSignal de las señales normales.

Evitar detección innecesaria (untracked)

Las señales utilizan el patrón Observer para gestionar las dependencias. En la práctica, al llamar a la señal para leer su valor, tanto en la plantilla como en funciones tipo computed o effect. se genera una subscripción a los cambios de dicha señal. Cuando el valor de la misma cambia, se notifica a las funciones dependientes, o al componente en cuya plantilla se accede al valor.

Esto significa que por ejemplo, en una función computed o effect, si accedemos al valor de varias señales, el cambio en cualquiera de ellas notificaría para que se volviese a calcular/ejecutar. Puede ocurrir también, que el valor de una señal se lea varias veces (varias subscripciones) o que llamemos a un método que indirectamente lea el valor de otra señal (se genera una subscripción también a dicha señal).

Si queremos que alguna señal, o código que pueda acceder a otras señales, no genere dependencias. Es decir, que no se recalcule/ejecute la función otra vez, lo podemos meter dentro de la función untracked. Si devolvemos algo dentro de esta función, a su vez es devuelto por la misma hacia fuera.

export class Ejemplo {
    a = signal(0);
    b = signal(0);
    c = computed(() => this.a() + untracked(this.b)); // Solo se actualiza cuando cambia el valor de a
}

Se considera una buena práctica guardar los valores en constantes de aquellas señales de las cuales queremos depender, y ejecutar el resto del código dentro de una función untracked. Esto no es necesario en ejemplos tan simples como el anterior, y se suele utilizar más en funciones tipo effect que veremos a continuación.

export class Ejemplo {
    b = signal(0);
    c = computed(() => {
        const a = this.a();
        return untracked(() => a + this.b()); // La lectura de b no genera dependencia
    }); // Este ejemplo está un poco forzado por razones pedagógicas
}

Señales mixtas: linkedSignal

En Angular 19 se introduce la función linkedSignal para crear señales. Es similar a la función computed pero devuelve una señal editable (WriteSignal). Esto permite cambiar su valor en cualquier momento, pero en el momento en que cambie una señal de la cual dependa, su valor será sobrescrito en base a lo que devuelva la función predefinida.


export class Ejemplo {
    a = signal(1);
    b = linkedSignal(() => a() * 2); // Inicialmente vale 2 (1 * 2)

    unMetodo() {
        b.set(10); // Ahora b vale 10
        a.set(3); // Ahora b vale 6 (3*2)
    }
}

Efectos: effect

Las funciones effect tienen sentido cuando dependen de alguna señal y sirven para ejecutar determinadas tareas en el momento en que el valor de alguna de ellas cambia. Al contrario que computed no devuelven ningún valor por lo que no se pueden utilizar para asignar valores dependientes de otras señales. En cualquier caso, siempre se ejecutan al menos la primera vez.

Este tipo de funciones se suelen crear en el constructor, ya que necesitan acceder al contexto de inyección de dependencias y una vez construido el objeto este ya no está accesible. 

export class Ejemplo {
    a = signal(1);

    constructor() {
        // Se ejecuta la primera vez, y después, cada vez que cambia el valor de a
        effect(() => console.log(`Valor de a: ${a()}`));
    }
}

Si se necesitan crear más tarde por alguna razón, se les puede pasar el contexto de inyección de dependencias manualmente, obteniendo una referencia con inject.

export class Ejemplo {
    a = signal(1);

    #injector = inject(Injector);

    otroMetodo() {
        // Se ejecuta la primera vez, y después, cada vez que cambia el valor de a
        effect(() => console.log(`Valor de a: ${a()}`), {injector: this.#injector});
    }
}

El uso recomendado de la función effect es el siguiente:

  • Hacer log de los datos y cuando cambian por motivos de depuración etc.
  • Sincronizar datos con localStorage
  • Manipular el DOM fuera de la automatización que ofrece Angular (por ejemplo, librerías JavaScript, etc.)

Aunque se aconseja evitarlo cuando sea posible, se pueden modificar señales dentro de la función effect. Esto se hace para evitar problemas como actualización circular de dependencias (bucle infinito donde se actualiza alguna señal que a su vez hace que se vuelva a ejecutar la función effect).

Por ello hay que tener varias cosas importantes en cuenta:

  • Siempre que se pueda, utilizar funciones alternativas como computed, o nuevas funciones de Angular 19 como linkedSignal o resource/rxResource, además de las funciones para operar con observables como toSignal y to Observable.
  • En algunos casos, o mientras se va aprendiendo lo mencionado arriba, no hay problema en cambiar señales dentro de effect con cuidado.
  • Es importante el uso de la función untracked. De esa manera no se generan accidentalmente dependencias innecesarias que pueden meternos en un bucle circular.

<< Plantillas Filtros (Pipes) >>