Creando y leyendo signals

Intermedio
Angular
Angular
Actualizado: 24/09/2025

Función signal() y lectura

La función signal() es el punto de entrada fundamental para crear signals en Angular. Esta función, importada desde @angular/core, nos permite crear contenedores reactivos que encapsulan valores y notifican automáticamente cuando estos cambian.

La sintaxis básica para crear un signal es extremadamente simple. La función signal() acepta un valor inicial como parámetro y devuelve un objeto signal que actúa como getter y setter del valor:

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

@Component({
  selector: 'app-user',
  standalone: true,
  template: `
    <h2>{{ username() }}</h2>
    <p>Edad: {{ age() }}</p>
  `
})
export class UserComponent {
  // Crear signals con diferentes tipos de datos
  username = signal('Juan Pérez');
  age = signal(25);
}

Lectura de valores

Para leer el valor de un signal, simplemente lo invocamos como si fuera una función. Esta característica hace que los signals sean intuitivos de usar, ya que la sintaxis es limpia y expresiva:

// Crear un signal
const contador = signal(0);

// Leer el valor actual
console.log(contador()); // Output: 0

// En templates, la lectura es igual de simple
template: `<span>Contador: {{ contador() }}</span>`

La lectura reactiva es uno de los aspectos más importantes de los signals. Cuando leemos un signal dentro de un contexto reactivo (como un template de componente), Angular automáticamente establece una dependencia y actualizará la vista cuando el valor cambie.

Creación con diferentes tipos de datos

Los signals son fuertemente tipados gracias a TypeScript, y pueden contener cualquier tipo de dato. Angular infiere automáticamente el tipo basándose en el valor inicial:

@Component({
  selector: 'app-examples',
  standalone: true,
  template: `
    <div>
      <h3>{{ title() }}</h3>
      <p>Activo: {{ isActive() ? 'Sí' : 'No' }}</p>
      <p>Items: {{ items().length }}</p>
      
      @if (user()) {
        <p>Usuario: {{ user()!.name }} - {{ user()!.email }}</p>
      }
    </div>
  `
})
export class ExamplesComponent {
  // Signal de string - tipo inferido: WritableSignal<string>
  title = signal('Mi Aplicación');
  
  // Signal de boolean - tipo inferido: WritableSignal<boolean>
  isActive = signal(true);
  
  // Signal de array - tipo inferido: WritableSignal<string[]>
  items = signal(['item1', 'item2', 'item3']);
  
  // Signal de objeto - tipo inferido: WritableSignal<User | null>
  user = signal<{name: string, email: string} | null>({
    name: 'Ana García',
    email: 'ana@email.com'
  });
  
  // Signal de número - tipo inferido: WritableSignal<number>
  score = signal(100);
}

Tipado explícito

Aunque Angular infiere los tipos automáticamente, podemos especificar tipos explícitamente cuando necesitamos mayor control:

interface Product {
  id: number;
  name: string;
  price: number;
}

@Component({
  selector: 'app-shop',
  standalone: true,
  template: `
    @if (selectedProduct()) {
      <div>
        <h3>{{ selectedProduct()!.name }}</h3>
        <p>Precio: {{ selectedProduct()!.price }}€</p>
      </div>
    }
    
    <p>Total productos: {{ products().length }}</p>
  `
})
export class ShopComponent {
  // Tipado explícito para mayor claridad
  selectedProduct = signal<Product | null>(null);
  
  // Array de productos con tipo explícito
  products = signal<Product[]>([
    { id: 1, name: 'Laptop', price: 999 },
    { id: 2, name: 'Mouse', price: 25 }
  ]);
  
  // Signal de unión de tipos
  status = signal<'loading' | 'success' | 'error'>('loading');
}

Lectura en métodos del componente

La lectura de signals en métodos del componente funciona igual que en templates. Simplemente invocamos el signal como función:

@Component({
  selector: 'app-calculator',
  standalone: true,
  template: `
    <div>
      <p>Valor A: {{ valueA() }}</p>
      <p>Valor B: {{ valueB() }}</p>
      <p>Suma: {{ calculateSum() }}</p>
      <button (click)="logValues()">Mostrar valores</button>
    </div>
  `
})
export class CalculatorComponent {
  valueA = signal(10);
  valueB = signal(20);
  
  calculateSum(): number {
    // Leer signals dentro de métodos
    return this.valueA() + this.valueB();
  }
  
  logValues(): void {
    // Acceder a valores actuales de los signals
    console.log('Valor A:', this.valueA());
    console.log('Valor B:', this.valueB());
    console.log('Suma:', this.calculateSum());
  }
}

Signals con valores complejos

Los signals pueden contener estructuras de datos complejas como objetos anidados, arrays de objetos, o cualquier tipo de dato JavaScript:

interface UserPreferences {
  theme: 'light' | 'dark';
  notifications: {
    email: boolean;
    push: boolean;
  };
  language: string;
}

@Component({
  selector: 'app-settings',
  standalone: true,
  template: `
    <div>
      <h3>Configuración de Usuario</h3>
      <p>Tema: {{ preferences().theme }}</p>
      <p>Email: {{ preferences().notifications.email ? 'Activado' : 'Desactivado' }}</p>
      <p>Push: {{ preferences().notifications.push ? 'Activado' : 'Desactivado' }}</p>
      <p>Idioma: {{ preferences().language }}</p>
    </div>
  `
})
export class SettingsComponent {
  preferences = signal<UserPreferences>({
    theme: 'light',
    notifications: {
      email: true,
      push: false
    },
    language: 'es'
  });
  
  getCurrentTheme(): string {
    // Acceder a propiedades anidadas
    return this.preferences().theme;
  }
}

La simplicidad de la lectura hace que los signals sean especialmente atractivos para desarrolladores que buscan un código limpio y expresivo. No hay necesidad de suscripciones manuales, ni de gestionar observables complejos para casos de uso básicos. Simplemente creamos el signal con signal() y lo leemos invocándolo como función, y Angular se encarga automáticamente de toda la reactividad.

Tipos WritableSignal y Signal

En el sistema de types de Angular, los signals se dividen en dos interfaces principales que definen claramente las capacidades de cada signal: WritableSignal<T> y Signal<T>. Esta distinción tipográfica es fundamental para entender el comportamiento y las restricciones de los signals en diferentes contextos.

WritableSignal

El tipo WritableSignal representa signals que pueden ser tanto leídos como modificados. Cuando creamos un signal usando la función signal(), obtenemos siempre un WritableSignal:

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

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div>
      <p>Contador: {{ count() }}</p>
      <button (click)="increment()">Incrementar</button>
    </div>
  `
})
export class CounterComponent {
  // Tipo explícito: WritableSignal<number>
  count: WritableSignal<number> = signal(0);
  
  increment(): void {
    // Podemos modificar porque es WritableSignal
    this.count.set(this.count() + 1);
  }
}

Los WritableSignal proporcionan métodos adicionales para la modificación del estado, como set() y update(), que no están disponibles en signals de solo lectura. El tipo incluye toda la funcionalidad de lectura más las capacidades de escritura.

Signal

El tipo Signal representa signals de solo lectura. Estos signals pueden ser leídos pero no modificados directamente. Este tipo es especialmente útil cuando queremos exponer datos de forma reactiva sin permitir modificaciones externas:

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

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    <div>
      <h2>{{ displayName() }}</h2>
      <p>Email: {{ email() }}</p>
      <input [value]="email()" (input)="updateEmail($event)" />
    </div>
  `
})
export class UserProfileComponent {
  // WritableSignal privado para el estado interno
  private _email: WritableSignal<string> = signal('usuario@email.com');
  private _firstName: WritableSignal<string> = signal('Juan');
  private _lastName: WritableSignal<string> = signal('Pérez');
  
  // Signal público de solo lectura para exposición externa
  email: Signal<string> = this._email;
  
  // Computed signal también es de tipo Signal (solo lectura)
  displayName: Signal<string> = computed(() => 
    `${this._firstName()} ${this._lastName()}`
  );
  
  updateEmail(event: Event): void {
    const target = event.target as HTMLInputElement;
    // Solo podemos modificar el WritableSignal privado
    this._email.set(target.value);
  }
}

La forma habitual de crear signals de solo lectura (Signal) es con la función computed(), que la exploraremos en una lección dedicada a ello.

Diferencias fundamentales

La diferencia principal entre ambos tipos radica en las operaciones permitidas:

@Component({
  selector: 'app-type-demo',
  standalone: true,
  template: `<div>{{ demonstration() }}</div>`
})
export class TypeDemoComponent {
  // WritableSignal: lectura + escritura
  writableSignal: WritableSignal<string> = signal('Editable');
  
  // Signal: solo lectura (computed signal)
  readOnlySignal: Signal<string> = computed(() => 
    `Procesado: ${this.writableSignal()}`
  );
  
  demonstration(): string {
    // ✅ Lectura permitida en ambos tipos
    const writableValue = this.writableSignal();
    const readOnlyValue = this.readOnlySignal();
    
    // ✅ Escritura permitida solo en WritableSignal
    this.writableSignal.set('Nuevo valor');
    
    // ❌ Error de TypeScript: Signal no tiene métodos set/update
    // this.readOnlySignal.set('Error'); // Property 'set' does not exist
    
    return `${writableValue} - ${readOnlyValue}`;
  }
}

Uso en APIs públicas

Los tipos de signals son especialmente importantes al diseñar APIs de componentes. Es una buena práctica exponer signals como Signal<T> (solo lectura) para evitar modificaciones accidentales desde componentes externos:

interface UserData {
  id: number;
  name: string;
  isActive: boolean;
}

@Component({
  selector: 'app-user-service',
  standalone: true,
  template: `
    <div>
      <h3>{{ currentUser()?.name || 'No user selected' }}</h3>
      <p>Status: {{ userStatus() }}</p>
    </div>
  `
})
export class UserServiceComponent {
  // Estado interno privado (WritableSignal)
  private _currentUser: WritableSignal<UserData | null> = signal(null);
  private _isLoading: WritableSignal<boolean> = signal(false);
  
  // API pública de solo lectura (Signal)
  currentUser: Signal<UserData | null> = this._currentUser;
  isLoading: Signal<boolean> = this._isLoading;
  
  // Computed signal automáticamente es Signal<T>
  userStatus: Signal<string> = computed(() => {
    if (this.isLoading()) return 'Cargando...';
    if (!this.currentUser()) return 'Sin usuario';
    return this.currentUser()!.isActive ? 'Activo' : 'Inactivo';
  });
  
  // Métodos públicos para modificar el estado
  loadUser(userData: UserData): void {
    this._isLoading.set(true);
    // Simulación de carga asíncrona
    setTimeout(() => {
      this._currentUser.set(userData);
      this._isLoading.set(false);
    }, 1000);
  }
  
  clearUser(): void {
    this._currentUser.set(null);
  }
}

Asignación de tipos

Angular permite asignaciones seguras entre estos tipos. Un WritableSignal<T> puede asignarse a un Signal<T> porque incluye toda la funcionalidad de lectura, pero no al revés:

@Component({
  selector: 'app-assignment-demo',
  standalone: true,
  template: `<p>{{ value() }}</p>`
})
export class AssignmentDemoComponent {
  private writableSignal: WritableSignal<number> = signal(42);
  
  // ✅ Válido: WritableSignal se puede asignar a Signal
  readOnlyReference: Signal<number> = this.writableSignal;
  
  // ❌ Error: Signal no se puede asignar a WritableSignal
  // writableReference: WritableSignal<number> = this.readOnlyReference;
  
  value(): number {
    // Ambos se leen de la misma manera
    return this.readOnlyReference();
  }
}

Parámetros de funciones

Los tipos de signals son especialmente útiles al definir funciones que reciben signals como parámetros:

@Component({
  selector: 'app-function-params',
  standalone: true,
  template: `
    <div>
      <p>{{ processReadOnly() }}</p>
      <p>{{ processWritable() }}</p>
    </div>
  `
})
export class FunctionParamsComponent {
  private data = signal('Datos iniciales');
  
  // Función que solo necesita leer el signal
  private formatData(source: Signal<string>): string {
    return `Formateado: ${source()}`;
  }
  
  // Función que necesita modificar el signal
  private updateAndFormat(target: WritableSignal<string>, newValue: string): string {
    target.set(newValue);
    return `Actualizado: ${target()}`;
  }
  
  processReadOnly(): string {
    // ✅ WritableSignal puede pasarse como Signal
    return this.formatData(this.data);
  }
  
  processWritable(): string {
    // ✅ Solo WritableSignal puede pasarse como WritableSignal
    return this.updateAndFormat(this.data, 'Nuevo valor');
  }
}

Esta distinción tipográfica proporciona seguridad en tiempo de compilación y hace que las intenciones del código sean más claras. Al usar Signal<T> en APIs públicas, comunicamos que el valor es reactivo pero inmutable desde el exterior, mientras que WritableSignal<T> indica que se espera la capacidad de modificación.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Angular es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Angular

Explora más contenido relacionado con Angular y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Comprender la función signal() para crear signals reactivos en Angular.
  • Aprender a leer valores de signals tanto en templates como en métodos de componentes.
  • Diferenciar entre WritableSignal y Signal, y sus usos respectivos.
  • Aplicar tipado explícito e inferido en signals para distintos tipos de datos.
  • Entender la importancia de exponer signals de solo lectura en APIs públicas para mantener la integridad del estado.