Angular 18: Signals and New Features

Discover Angular 18 Signals, zoneless change detection, and new signal-based APIs to build more performant applications.

Illustration of Angular 18 with Signals and modern reactivity

Angular 18 marks a turning point in the framework's evolution with the stabilization of Signals. This new reactive primitive fundamentally transforms how Angular components are built, offering a modern alternative to traditional decorators while paving the way for Zone.js-free change detection.

What you will learn

Angular 18's signal-based APIs: input(), model(), viewChild(), and zoneless configuration for lighter and more performant applications.

Understanding Signals in Angular 18

Signals represent a new approach to reactivity in Angular. Unlike classic decorators like @Input() that rely on Zone.js change detection, Signals offer fine-grained and explicit reactivity. Each Signal encapsulates a value and automatically notifies consumers when that value changes.

This approach offers several advantages: better performance through targeted updates, native integration with computed() and effect() functions, and preparation for Angular's zoneless future.

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 work as reactive containers: signal() creates a writable Signal, computed() derives calculated values, and effect() allows executing actions in response to changes.

Signal Inputs with input()

The input() function replaces the traditional @Input() decorator. It returns a read-only InputSignal, ensuring that data always flows from parent to child without accidental modification.

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);
  });
}

Usage in a parent template remains similar, but with type safety and Signal reactivity:

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');
}

The major difference from @Input(): signal inputs are read-only. Calling this.book.set() from the child component is impossible, which reinforces unidirectional data flow.

Two-Way Binding with model()

For cases requiring bidirectional synchronization, Angular 18 introduces model(). This function creates a writable Signal that automatically propagates changes to the parent component.

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());
    }
  }
}

The parent uses the banana-in-a-box syntax [()] for bidirectional binding:

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()

Use input() for read-only data (parent → child). Use model() when the child component needs to modify the value (bidirectional).

Signal Queries with viewChild() and contentChild()

The viewChild(), viewChildren(), contentChild(), and contentChildren() functions replace their corresponding decorators. They return Signals, eliminating the need for lifecycle hooks like 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');
    }
  }
}

For projecting content and accessing it, contentChild() works similarly:

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);
}

Ready to ace your Angular interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Zoneless Change Detection

Angular 18 introduces Zone.js-free change detection in experimental mode. This feature reduces bundle size by approximately 13 KB and improves performance by eliminating monkey-patches on browser async APIs.

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()
  ]
});

The angular.json configuration must also be updated to remove Zone.js:

json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

In zoneless mode, change detection triggers automatically in these cases: Signal update, markForCheck() call, new value received via AsyncPipe, or component attach/detach.

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);
    }
  }
}
Zoneless Compatibility

Components using ChangeDetectionStrategy.OnPush and Signals are generally compatible with zoneless mode. Avoid direct modifications of properties that are not Signals.

Migrating Existing Components

Migration to signal-based APIs can be done gradually. Here's an example of refactoring a traditional component:

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);
    });
  }
}

Benefits of this migration: stricter typing, automatic reactivity, less boilerplate code, and zoneless mode compatibility.

Best Practices with Signals

Key recommendations to get the most out of Signals in 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));
  }
}

Key points to remember:

  • Use computed() for derived values rather than recalculating in the template
  • Prefer update() over set() when the new value depends on the old one
  • Use untracked() in effects to avoid circular dependencies
  • Always specify track in @for loops to optimize rendering

Conclusion

Angular 18 lays the foundation for a Zone.js-free future through Signals. Key takeaways:

  • input() replaces @Input() with stricter typing and guaranteed read-only access
  • model() enables reactive two-way binding between parent and child
  • viewChild() and contentChild() eliminate the need for lifecycle hooks
  • Zoneless reduces bundle size and improves performance
  • computed() and effect() complete the reactive ecosystem
  • ✅ Gradual migration is possible component by component

Adopting Signals prepares Angular applications for future versions where zoneless mode will become the norm. This transition represents a wise investment for long-term maintainability and performance.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

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

Share

Related articles