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.

Ilustracion de Angular 18 con Signals y reactividad moderna

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.

Lo que se aprende en este articulo

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.

signals-basics.component.tstypescript
// 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.

book-card.component.tstypescript
// 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:

book-list.component.tstypescript
// 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.

search-input.component.tstypescript
// 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:

app.component.tstypescript
// 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)
    );
  });
}
model() vs input()

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.

form-container.component.tstypescript
// 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:

card.component.tstypescript
// 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.

main.tstypescript
// 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:

json
{
  "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.

zoneless-counter.component.tstypescript
// 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);
    }
  }
}
Compatibilidad Zoneless

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:

typescript
// 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);
  }
}
typescript
// 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:

best-practices.component.tstypescript
// 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() sobre set() cuando el nuevo valor depende del anterior
  • Usar untracked() en effects para evitar dependencias circulares
  • Siempre especificar track en los bucles @for para 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

#angular 18
#angular signals
#zoneless
#signal inputs
#reactivity

Compartir

Artículos relacionados