Angular 18: Signals y Nuevas Funcionalidades
Angular 18 estabiliza Signals como primitiva reactiva, introduce APIs basadas en signals y habilita la deteccion de cambios sin Zone.js para aplicaciones mas performantes.

Angular 18 marca un punto de inflexion en la evolucion del framework con la estabilizacion de Signals. Esta nueva primitiva reactiva transforma fundamentalmente la forma de construir componentes Angular, ofreciendo una alternativa moderna a los decoradores tradicionales y abriendo camino hacia la deteccion de cambios sin Zone.js.
Las APIs basadas en signals de Angular 18: input(), model(), viewChild() y la configuracion zoneless para aplicaciones mas ligeras y performantes.
Entendiendo Signals en Angular 18
Signals representan un nuevo enfoque de reactividad en Angular. A diferencia de los decoradores clasicos como @Input() que dependen de la deteccion de cambios de Zone.js, Signals ofrece reactividad granular y explicita. Cada Signal encapsula un valor y notifica automaticamente a los consumidores cuando ese valor cambia.
Este enfoque aporta varias ventajas: mejor rendimiento gracias a actualizaciones precisas, integracion nativa con las funciones computed() y effect(), y preparacion para el futuro zoneless de Angular.
// Demonstration of fundamental Signal concepts
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter-container">
<h2>Counter: {{ count() }}</h2>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Writable signal - value can be modified
count = signal(0);
// Computed signal - automatically derived from count
// Only recalculates when count changes
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - executed on every count change
// Useful for side effects (logs, API calls, etc.)
effect(() => {
console.log(`New counter value: ${this.count()}`);
});
}
increment() {
// update() allows modification based on previous value
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() directly replaces the value
this.count.set(0);
}
}Signals funcionan como contenedores reactivos: signal() crea un Signal de escritura, computed() deriva valores calculados y effect() permite ejecutar acciones en respuesta a cambios.
Signal Inputs con input()
La funcion input() reemplaza al decorador tradicional @Input(). Retorna un InputSignal de solo lectura, garantizando que los datos siempre fluyan de padre a hijo sin modificaciones accidentales.
// Component using signal inputs
import { Component, input, computed } from '@angular/core';
interface Book {
id: string;
title: string;
author: string;
price: number;
discountPercent?: number;
}
@Component({
selector: 'app-book-card',
standalone: true,
template: `
<article class="book-card">
<h3>{{ book().title }}</h3>
<p class="author">By {{ book().author }}</p>
@if (hasDiscount()) {
<p class="price">
<span class="original">\${{ book().price }}</span>
<span class="discounted">\${{ discountedPrice() }}</span>
</p>
} @else {
<p class="price">\${{ book().price }}</p>
}
@if (featured()) {
<span class="badge">Featured</span>
}
</article>
`
})
export class BookCardComponent {
// Required input - template won't compile without this prop
book = input.required<Book>();
// Optional input with default value
featured = input(false);
// Computed based on input - automatically recalculated
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// Discounted price calculation
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}El uso en un template padre se mantiene similar, pero con type safety y reactividad Signal:
// Parent component using book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
<div class="book-grid">
@for (book of books(); track book.id) {
<app-book-card
[book]="book"
[featured]="book.id === featuredBookId()"
/>
}
</div>
`
})
export class BookListComponent {
books = signal<Book[]>([
{ id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
{ id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
]);
featuredBookId = signal('1');
}La diferencia principal con @Input(): los signal inputs son de solo lectura. Llamar a this.book.set() desde el componente hijo es imposible, lo que refuerza el flujo de datos unidireccional.
Two-Way Binding con model()
Para los casos que requieren sincronizacion bidireccional, Angular 18 introduce model(). Esta funcion crea un Signal de escritura que propaga automaticamente los cambios al componente padre.
// Component with bidirectional binding via model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
<div class="search-container">
<input
type="text"
[value]="query()"
(input)="onInput($event)"
[placeholder]="placeholder()"
class="search-input"
/>
@if (query().length > 0) {
<button (click)="clear()" class="clear-btn">×</button>
}
<span class="char-count">{{ charCount() }} characters</span>
</div>
`
})
export class SearchInputComponent {
// model() creates a bidirectional Signal
// Modifications propagate to parent
query = model('');
// Classic input for configuration
placeholder = model('Search...');
// Output for additional events
searchSubmitted = output<string>();
// Computed based on model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// Update model - propagates to parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}El componente padre utiliza la sintaxis banana-in-a-box [()] para el binding bidireccional:
// Using two-way binding with model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
<div class="app-container">
<app-search-input [(query)]="searchTerm" />
<p>Current search: {{ searchTerm() }}</p>
<div class="results">
@for (result of filteredResults(); track result.id) {
<div class="result-item">{{ result.name }}</div>
}
</div>
</div>
`
})
export class AppComponent {
// Local signal synchronized with child component
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// Reactive filtering based on searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}Usar input() para datos de solo lectura (padre a hijo). Usar model() cuando el componente hijo necesita modificar el valor (bidireccional).
Signal Queries con viewChild() y contentChild()
Las funciones viewChild(), viewChildren(), contentChild() y contentChildren() reemplazan a sus decoradores correspondientes. Retornan Signals, eliminando la necesidad de lifecycle hooks como ngAfterViewInit.
// Demonstration of signal queries
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';
@Component({
selector: 'app-form-container',
standalone: true,
imports: [FormFieldComponent],
template: `
<form #formElement (submit)="onSubmit($event)">
<input #firstInput type="text" placeholder="Name" />
<app-form-field label="Email" />
<app-form-field label="Phone" />
<div class="actions">
<button type="submit">Submit</button>
<button type="button" (click)="focusFirst()">Focus first field</button>
</div>
</form>
`
})
export class FormContainerComponent {
// viewChild returns Signal<ElementRef | undefined>
formElement = viewChild<ElementRef>('formElement');
// viewChild.required guarantees element exists
firstInput = viewChild.required<ElementRef<HTMLInputElement>>('firstInput');
// Query on a component - returns the component itself
firstFormField = viewChild(FormFieldComponent);
// viewChildren for multiple elements
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect replaces ngAfterViewInit for queries
effect(() => {
// Signal is automatically resolved
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// React to list changes
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// Direct access via Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// Access the form
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}Para proyectar contenido y acceder a el, contentChild() funciona de manera similar:
// Using contentChild for projected content
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header class="card-header">
<ng-content select="[card-title]" />
</header>
<div class="card-body">
<ng-content />
</div>
@if (hasFooter()) {
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
}
</div>
`
})
export class CardComponent {
// Detect if footer was projected
footerContent = contentChild<ElementRef>('[card-footer]');
// Computed to check footer presence
hasFooter = computed(() => this.footerContent() !== undefined);
}¿Listo para aprobar tus entrevistas de Angular?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Deteccion de Cambios Zoneless
Angular 18 introduce la deteccion de cambios sin Zone.js en modo experimental. Esta funcionalidad reduce el tamano del bundle en aproximadamente 13 KB y mejora el rendimiento al eliminar los monkey-patches en las APIs asincronas del navegador.
// Configuring the application in zoneless mode
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// Enable experimental zoneless detection
provideExperimentalZonelessChangeDetection()
]
});La configuracion de angular.json tambien debe actualizarse para remover Zone.js:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}En modo zoneless, la deteccion de cambios se activa automaticamente en estos casos: actualizacion de Signal, llamada a markForCheck(), nuevo valor recibido via AsyncPipe, o attach/detach de componente.
// Component optimized for zoneless mode
import {
Component,
signal,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-zoneless-counter',
standalone: true,
// OnPush recommended for zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<p>Counter: {{ count() }}</p>
<button (click)="increment()">Increment</button>
@if (loading()) {
<p>Loading...</p>
}
@if (data()) {
<pre>{{ data() | json }}</pre>
}
</div>
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal<any>(null);
increment() {
// Signal update triggers detection
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals guarantee view updates
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}Los componentes que usan ChangeDetectionStrategy.OnPush y Signals son generalmente compatibles con el modo zoneless. Evitar modificaciones directas de propiedades que no sean Signals.
Migracion de Componentes Existentes
La migracion a APIs basadas en signals puede realizarse de forma gradual. A continuacion, un ejemplo de refactorizacion de un componente tradicional:
// BEFORE: Component with classic decorators
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
<div #container>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class UserProfileLegacyComponent implements AfterViewInit {
@Input() user!: { name: string; email: string };
@ViewChild('container') container!: ElementRef;
ngAfterViewInit() {
console.log('Container ready:', this.container.nativeElement);
}
}// AFTER: Component migrated to Signals
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';
interface User {
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<div #container>
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
</div>
`
})
export class UserProfileComponent {
// input.required replaces @Input() with !
user = input.required<User>();
// viewChild.required replaces @ViewChild with !
container = viewChild.required<ElementRef>('container');
constructor() {
// effect replaces ngAfterViewInit for queries
effect(() => {
console.log('Container ready:', this.container().nativeElement);
});
}
}Beneficios de esta migracion: tipado mas estricto, reactividad automatica, menos codigo boilerplate y compatibilidad con el modo zoneless.
Buenas Practicas con Signals
Recomendaciones clave para aprovechar al maximo Signals en Angular 18:
// Example of best practices with Signals
import {
Component,
signal,
computed,
effect,
untracked,
ChangeDetectionStrategy
} from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="cart">
<h2>Cart ({{ itemCount() }} items)</h2>
@for (item of items(); track item.id) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × \${{ item.price }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
}
<div class="cart-total">
<strong>Total: \${{ total() }}</strong>
</div>
</div>
`
})
export class CartComponent {
// Signal for mutable data
items = signal<Product[]>([]);
// Computed for derived values - avoids unnecessary recalculations
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect for side effects (analytics, persistence)
effect(() => {
const currentItems = this.items();
// untracked avoids creating a dependency
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() for modifications based on previous state
this.items.update(current => {
const existing = current.find(i => i.id === product.id);
if (existing) {
return current.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...current, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(current => current.filter(i => i.id !== id));
}
}Puntos clave a recordar:
- Usar
computed()para valores derivados en lugar de recalcular en el template - Preferir
update()sobreset()cuando el nuevo valor depende del anterior - Usar
untracked()en effects para evitar dependencias circulares - Siempre especificar
tracken los bucles@forpara optimizar el renderizado
Conclusion
Angular 18 sienta las bases para un futuro sin Zone.js a traves de Signals. Puntos clave:
- input() reemplaza a
@Input()con tipado mas estricto y acceso de solo lectura garantizado - model() habilita two-way binding reactivo entre padre e hijo
- viewChild() y contentChild() eliminan la necesidad de lifecycle hooks
- Zoneless reduce el tamano del bundle y mejora el rendimiento
- computed() y effect() completan el ecosistema reactivo
- La migracion gradual es posible componente por componente
Adoptar Signals prepara las aplicaciones Angular para futuras versiones donde el modo zoneless sera la norma. Esta transicion representa una inversion inteligente para la mantenibilidad y el rendimiento a largo plazo.
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Etiquetas
Compartir
