En esta página

Effects, linkedSignal y resource

12 min lectura TextoCap. 2 — Reactividad

Effects: efectos secundarios reactivos

Un effect() ejecuta una función cada vez que los signals que lee cambian. Es la herramienta para efectos secundarios: sincronizar con localStorage, enviar analytics, hacer logging, etc.

import { signal, effect } from '@angular/core';

const tema = signal<'claro' | 'oscuro'>('oscuro');

// Se ejecuta al crear y cada vez que tema() cambia
effect(() => {
  document.documentElement.setAttribute('data-theme', tema());
});

Reglas de los effects

  1. Solo en contexto de inyección — Dentro de un constructor, un campo del componente o runInInjectionContext
  2. Se limpian automaticamente — Cuando el componente se destruye
  3. No deben escribir en signals — Salvo con allowSignalWrites (no recomendado)

Cleanup en effects

Si tu effect registra listeners o timers, puedes limpiarlos con onCleanup:

effect((onCleanup) => {
  const id = setInterval(() => {
    console.log('Tick para:', usuarioId());
  }, 1000);

  onCleanup(() => clearInterval(id));
});

linkedSignal: signal derivado escribible

linkedSignal crea un signal que se computa automaticamente cuando su fuente cambia, pero que también puedes escribir manualmente:

import { signal, linkedSignal } from '@angular/core';

const categoriaId = signal(1);

// Se resetea a 1 cada vez que categoriaId cambia
const página = linkedSignal<number>({
  source: categoriaId,
  computation: () => 1,
});

// Puedes escribirlo manualmente
página.set(3);

// Pero al cambiar categoriaId, vuelve a 1
categoriaId.set(2); // página() === 1

Casos de uso tipicos:

  • Paginación que se resetea al cambiar filtros
  • Pestana activa que vuelve a la primera al navegar
  • Formularios que se reinician al seleccionar otro registro

resource: carga asincrona reactiva

La API resource conecta signals con operaciones asincronas. Cuando las dependencias cambian, el resource se recarga automaticamente:

import { signal, resource } from '@angular/core';

const busqueda = signal('angular');

const resultados = resource({
  request: () => ({ q: busqueda() }),
  loader: async ({ request, abortSignal }) => {
    const resp = await fetch(
      `https://api.ejemplo.com/buscar?q=${request.q}`,
      { signal: abortSignal },
    );
    return resp.json();
  },
});

Propiedades del resource

Propiedad Tipo Descripcion
.value() T | undefined El valor cargado
.isLoading() boolean Si esta cargando
.error() unknown El error, si ocurrio
.status() ResourceStatus Estado detallado
.reload() void Recarga manualmente

rxResource para Observables

Si prefieres trabajar con Observables (ej. HttpClient), usa rxResource:

import { rxResource } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

const http = inject(HttpClient);
const id = signal(1);

const datos = rxResource({
  request: () => ({ id: id() }),
  loader: ({ request }) => http.get(`/api/datos/${request.id}`),
});

Práctica

  1. Crea un effect de persistencia: Implementa un signal para el tema ('claro' | 'oscuro') y un effect() que guarde el valor en localStorage cada vez que cambie.
  2. Usa linkedSignal: Crea un signal categoriaId y un linkedSignal para la pagina actual que se resetee a 1 cada vez que cambie la categoria. Agrega botones para cambiar categoria y avanzar pagina.
  3. Implementa un resource: Usa la API resource para cargar datos de https://jsonplaceholder.typicode.com/users/{id} reactivamente. Muestra estados de carga, error y datos con @if.

En la siguiente leccion veremos como los componentes se comunican entre si usando inputs, outputs y model.

No abuses de effect()
Los effects deben usarse para efectos secundarios (localStorage, analytics, logs). Si solo necesitas derivar un valor, usa computed(). Si necesitas un signal derivado que puedas escribir, usa linkedSignal().
resource cancela automaticamente
Cuando las dependencias de un resource cambian, la petición anterior se cancela automaticamente via AbortSignal. No necesitas gestionar la cancelacion manualmente.
import {
  Component, signal, computed, effect,
  linkedSignal, resource,
  ChangeDetectionStrategy,
} from '@angular/core';

interface Usuario {
  id: number;
  nombre: string;
  email: string;
}

@Component({
  selector: 'app-perfil',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './perfil.html',
})
export class Perfil {
  readonly usuarioId = signal(1);

  // linkedSignal: signal derivado que se puede escribir
  readonly pestanaActiva = linkedSignal<string>({
    source: this.usuarioId,
    computation: () => 'info',  // resetea al cambiar de usuario
  });

  // resource: carga asincrona reactiva
  readonly usuarioResource = resource<Usuario, { id: number }>({
    request: () => ({ id: this.usuarioId() }),
    loader: async ({ request, abortSignal }) => {
      const resp = await fetch(
        `https://api.ejemplo.com/usuarios/${request.id}`,
        { signal: abortSignal },
      );
      if (!resp.ok) throw new Error('Error al cargar usuario');
      return resp.json();
    },
  });

  // Acceso al estado del resource
  readonly usuario = computed(() => this.usuarioResource.value());
  readonly cargando = computed(() => this.usuarioResource.isLoading());
  readonly error = computed(() => this.usuarioResource.error());

  constructor() {
    // Effect para sincronizar con almacenamiento local
    effect(() => {
      const id = this.usuarioId();
      localStorage.setItem('ultimoUsuario', String(id));
      console.log('Usuario seleccionado:', id);
    });
  }

  cambiarUsuario(id: number): void {
    this.usuarioId.set(id);
  }

  cambiarPestana(tab: string): void {
    this.pestanaActiva.set(tab);
  }
}