Getting Started with Angular Signals: A Comprehensive Guide

In the ever-evolving landscape of front-end development, Angular continues to be a popular framework for building dynamic and responsive web applications. With Angular’s consistent updates, developers are always on the lookout for new tools and features that can simplify their workflows and improve application performance. One such feature that has garnered attention is Angular Signals.

Angular Signals offer a reactive approach to managing state in your applications, making it easier to track and respond to changes. In this blog post, we’ll dive deep into what Angular Signals are, how they work, and how you can leverage them to build more efficient and maintainable applications.

What are Angular Signals?

Angular Signals are a new reactive primitive introduced in Angular to handle state changes and data flows more effectively. Unlike traditional state management solutions that might require extensive boilerplate code, Signals provide a more intuitive and declarative way to react to data changes in your application.

A Signal in Angular is essentially a reactive value holder. It emits updates whenever the data it holds changes, allowing your application to automatically respond to these changes without requiring explicit subscription management.

Why Use Angular Signals?

  1. Performance: Signals allow for fine-grained reactivity, meaning only the components that depend on a particular piece of data are re-rendered when it changes. This can significantly improve the performance of your application.
  2. Simplicity: Signals are easy to understand and use. They reduce the need for complex state management patterns, making your codebase cleaner and more maintainable.
  3. Reactivity: With Signals, your components automatically re-render when the data they depend on changes. This leads to more responsive and dynamic UIs.
  4. Type Safety: Angular Signals are fully type-safe, integrating seamlessly with TypeScript. This ensures that your state management is robust and less prone to runtime errors.
  5. Easier Testing: Signals provide fine-grained reactivity, meaning only the parts of your application that depend on a specific Signal are updated when it changes. This behavior makes it easier to test individual components or services
  6. Current Angular Standard: The Angular team have been working towards moving their change detection away from zone.js for as long as Angular 16. Using Angular Signals is one way to make your app a little more future proof with many of the zoneless updates Angular is going through.
  7. Easy Cleanup: Unlike Observables, Signals don’t really need to be destroyed when your subscriber is destroyed. This means that you don’t have to worry about memory leaks as much.

How to Use Angular Signals

Engage Turn Signals.

Before we get started using Angular Signals, let me first give you a word of warning 🚦. This section is LONG. I’ve included a set of navigational links below to quickly scroll to the section you want to learn about.

Writable Signals

Animated picture of a man writing.

In Angular, a writable signal is a type of signal that allows both reading and updating the value it holds. Writable signals are essential in scenarios where you need to manage and modify the state within your application, providing a straightforward and reactive way to handle such state changes.

TypeScript
// writeable signals have the type WriteableSignal
// this sets the initial value to 0
const count: WriteableSignal<number> = signal(0); 

// using count as a function acts as a getter for the current value
console.log('The count is: ' + count());

// you can use set() to change the value
count.set(3);

// or you can use update to compute a new value based on the old value
count.update(value => value +1);

Computed Signals

a man doing complex computations in his head

A computed signal is essentially a signal whose value is derived from one or more other signals. Unlike writable signals, you do not directly set the value of a computed signal. Instead, the value is computed automatically based on the logic you define and the current values of its dependencies. Computed signals are read-only.

TypeScript
const count: WriteableSignal<number> = signal(0);
// a computed signal based on a writeable signal above
const doubleCount: Signal<number> = computed(() => count() * 2);
// computed signals are not writeable and so the following will throw an error
doubleCount.set(3);

Computed signals use dynamic dependencies

Computed signals are lazily evaluated and memoized. This means that doubleCount won’t evaluate until count has a value (which it is initialized with) and it’s value is cached until count is updated. This dynamic dependency system also works with nested signals.

TypeScript
// this signal acts as a feature flag
const showCount = signal(false);
// our hero the count (not dracula)
const count = signal(0);
// our computed signal acting as a wrapper
const conditionalCount = computed(() => {
  // if showCount is false here, then count() below is not even evaluated
  // if count() changes while showCount is false this function will
  // still not be recompiled
  if(showCount()) {
    // nested signal
    return `The count is ${count()}.`;
  } else {
    return 'Nothing to see here';
  }
}

Effects on Angular Signals

that one might have an after effect.

Effects in Angular Signals are a mechanism that allows you to execute side effects in response to changes in signals. Side effects are operations that do not return a value but instead interact with external systems, such as making HTTP requests, updating the DOM, logging information, or triggering other actions outside of the core Angular reactivity system.

Effects always run at least once and similar to computed signals they are memoized and track their dynamic dependencies. Effects are also always run asynchronously during change detection.

TypeScript
effect(() => {
  console.log(`The current count is: ${count()}`);
});

When to Use Effects

  • Replacement for ngOnChanges: When using Inputs, Effects can be used to replace the need for `ngOnChanges`
  • Logging: Use Effects to log data or analytics.
  • Keeping in sync with local storage: Use Effects when you want to send state data to window.localStorage
  • Custom DOM Behavior: Use Effects to handle DOM behavior that you can’t express with template syntax.
  • Updates to Canvas: Use Effects to update a canvas
  • Custom Rendering Integrations: Use Effects when using charting libraries or certain UI libraries.

When NOT to Use Effects

  • State Changes: Using Effects to propagate state changes (aka updating signals) can result in an ExpressionChangedAfterItHasBeenChecked error, infinite circular updates, or unnecessary change detection cycles.
  • Complex Effects: Using complicated large Effects is a code smell and should be avoided. There is a good chance that there is another better way to do what you want to do.

Effect Injection Context

Angular Effects rely on an injection context in order to work correctly. We have 3 ways to create an effect() that satisfies this need.

TypeScript
/*
    Option 1:
    Create the effect in a constructor of a component, directive, or service.
*/

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor() {
    // Register a new effect.
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    });
  }
}
TypeScript
/*
    Option 2:
    Assign the effect to a property with a nice descriptive name
*/

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  private loggingEffect = effect(() => {
    console.log(`The count is: ${this.count()}`);
  });
}
TypeScript
/*
    Option 3:
    Pass an Injector to your Effect
*/

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  // use DI to include Injector
  constructor(private injector: Injector) {}
  initializeLogging(): void {
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    }, {injector: this.injector}); // reference the Injector in the effect
  }
}

Destroying and Cancelling Effects

Angular already handles destroying an effect when it’s parent is destroyed. However, you do have the ability to manually destroy Effects. Effects return an EffectRef that you can call .destroy() on to manually destroy the Effect. You can combine this with the manualCleanup option (see in RxJS Interop section) that allows you disable automatic cleanup. Be careful with this though, because you now open yourself up to memory leaks.

There are several instances when you may have started a long running Effect but now want to cancel that action. Angular provides an onCleanup callback to handle this situation. onCleanup is called anytime the effect is called again or when the parent component is destroyed.

TypeScript
effect((onCleanup) => {
  const user = currentUser();
  const timer = setTimeout(() => {
    console.log(`1 second ago, the user became ${user}`);
  }, 1000);
  onCleanup(() => {
    clearTimeout(timer);
  });
});

Inputs as Signals

As of Angular 18, Inputs as Signals is in Developer Preview.

input. lol.

You may be used to @Input() syntax in older versions of Angular as a way to handle one way component input values. With signals we have an entirely new way to handle component inputs. In fact, we now have two new types of inputs.

  1. Optional Inputs: input – As the name implies, these are optional inputs for a component to function correctly. You can initialize it with a default value or you can leave it undefined
  2. Required Inputs: input.required – Required inputs always have a value and are a required value for the component to work.

Here is an example of how to initialize Input Signals

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

@Component({...})
export class MyComp {
  // optional
  firstName = input<string>();         // InputSignal<string|undefined>
  age = input(0);                      // InputSignal<number>
  
  // required
  lastName = input.required<string>(); // InputSignal<string>
}

An Input Signal is just an extension of a Signal

TypeScript
export class InputSignal<T> extends Signal<T> { ... }

And because of this, you can use the same signal syntax to reference your inputs in your templates

TypeScript
<p>First name: {{firstName()}}</p>
<p>Last name: {{lastName()}}</p>

Input Aliases

You can also alias the name of your input, similar to how you could with @Input()

TypeScript
class StudentDirective {
  // allows users to bind to [studentAge] instead of [age] in a template
  age = input(0, {alias: 'studentAge'});
}

Monitoring Input Changes

Since Inputs are just an extension of Signals, we have all the benefits that signals gives us. That means that we can create computed signals from our inputs, but we can also create effects based on Input changes.

TypeScript
import {input, effect} from '@angular/core';
class MyComp {
  firstName = input.required<string>();
  constructor() {
    effect(() => {
      console.log(this.firstName());
    });
  }
}

Input Value Transforms

Every now and then, you may run into a time when you need to change an Input value based on what was inputted. It’s important to know that Input Value Transforms should not change the meaning of the input and should only use pure functions.

TypeScript
class MyComp {
  disabled = input(false, {
    transform: (value: boolean|string) => 
      typeof value === 'string' ? value === '' : value,
  });
}
HTML
<my-comp disabled>

In the example above, we are creating an input named disabled that accepts the types boolean and string. We then parsing the type and value to convert to always be a boolean. This allows the user to just pass an empty string to mark the component disabled, as seen in the html example above.

WARNING: Do not use input value transforms if they would change the meaning of the input or are required to be impure. Instead, use computed signals for transformations that change the meaning and effects for functions that would create impure changes.

Model Inputs

As of Angular 18, Inputs as Signals is in Developer Preview.

I'm a model!

Model inputs in Angular are inputs but with two way binding. This means that they act similar to @Input() and @Output() all in one value. In other words, a child component can consume values provided from a parent and then send updates back up to the parent.

TypeScript
import {Component, model, input} from '@angular/core';
@Component({
  selector: 'custom-checkbox',
  template: '<div (click)="toggle()"> ... </div>',
})
export class CustomCheckbox {
  // model input - two way binding
  checked = model(false);
  // regular input - one way binding
  disabled = input(false);
  toggle() {
    // While standard inputs are read-only, you can write directly to model inputs.
    this.checked.set(!this.checked());
  }
}

Like Inputs, Model Inputs can be marked as required or be given an alias. However, Model Inputs do not support input transforms.

TypeScript
import {Component, model, input} from '@angular/core';
@Component({...})
export class CustomCheckbox {
  // optional
  checked = model(false);
  // optional with alias
  longName = model("name", {alias: `shortName`});
  // required
  disabled = model.required(false);
}

Two-way Binding Angular Model Inputs with Signals

The simplest way to handle two-way binding with a component is with plain properties

TypeScript
@Component({
  ...,
  // `checked` is a model input.
  // The parenthesis-inside-square-brackets syntax (aka "banana-in-a-box") creates a two-way binding
  template: '<custom-checkbox [(checked)]="isAdmin" />',
})
export class UserProfile {
  protected isAdmin = false;
}

In the above example, isAdmin and checked from the child component will stay in sync.

TypeScript
@Component({
  ...,
  // `checked` is a model input.
  // The parenthesis-inside-square-brackets syntax (aka "banana-in-a-box") creates a two-way binding
  template: '<custom-checkbox [(checked)]="isAdmin" />',
})
export class UserProfile {
  protected isAdmin = signal(false);
}

You can also use Signals in your two-way binding to gain all of the advantages in change detection that Signals provides. In the above example, isAdmin is a signal instead of a plain value. CustomCheckbox will know this is a signal and will update it appropriately.

Model Inputs Create Implicit change Events

When a model is created in a component, Angular will automatically create a corresponding Output() for that model suffixed with “Change”

TypeScript
@Directive({...})
export class CustomCheckbox {
  // This automatically creates an output named "checkedChange".
  // Can be subscribed to using `(checkedChange)="handler()"` in the template.
  checked = model(false);
}

Queries as Signals

As of Angular 18, Inputs as Signals is in Developer Preview.

A detective

Queires as signals is a way to query child elements and read values from them. If you are familiar with @ViewChild() then this will make sense. Queries come in two categories, view queries and content queries. The query is provided back via a signal primitive and thus, you can use computed and effect to handle the results.

View Queries

Use view queries to traverse the DOM of your components own template (view).

Similar to @ViewChild() you can use viewChild to return an signal of an element or component. Because this is using signals, you don’t have to deal with ngAfterViewInit like you would with the @ViewChild().

TypeScript
@Component({
  template: `
    <div #el></div>
    <my-component />
  `
})
export class TestComponent {
  // query for a single result by a string predicate  
  divEl = viewChild<ElementRef>('el')  // Signal<ElementRef|undefined>
  // query for a single result by a type predicate
  cmp = viewChild(MyComponent);        // Signal<MyComponent|undefined>
  // querfy a single result with options
  cmpEl = viewChild(MyComponent, {read: ElementRef});   // Signal<ElementRef|undefined>
}

But what if you want an array of elements or components? This is where viewChildren comes into play!

TypeScript
@Component({
  template: `
    <div #el></div>
    @if (show) {
      <div #el></div>
    }
  `
})
export class TestComponent {
  show = true;
  // query for multiple results
  divEls = viewChildren<ElementRef>('el');    // Signal<ReadonlyArray<ElementRef>>
}

Content Queries

Content queries work similar to @ContentChild() in that they get the content wrapped inside the component tags.

TypeScript
<TestComponent>
  <div #h></div>
  <MyHeader />
</TestComponent>

---

@Component({...})
  export class TestComponent {
  // query by a string predicate  
  headerEl = contentChild<ElementRef>('h');   // Signal<ElementRef|undefined>
  // query by a type predicate
  header = contentChild(MyHeader);            // Signal<MyHeader|undefined>
}

You can also grab an array of content children with… contentChildren

TypeScript
@Component({...})
export class TestComponent {
  // query for multiple results
  divEls = contentChildren<ElementRef>('h');  // Signal<ReadonlyArray<ElementRef>>
  // query with options
  // descendants will query deeper than direct children
  // read will ensure to return an ElementRef instead of MyHeader
  deepEls = contentChildren(MyHeader, {descendants: true, read: ElementRef});
}

Required Queries

By default, Angular will return undefined when it can’t find a result for a query. But most of the time a developer will author their code so that there is always a result. When this is the intent of the developer, we can use required to indicate the query should return something other than undefined. In fact, if we use required but the query finds no results, then Angular will throw an error.

TypeScript
@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <div #requiredEl></div>
  `,
})
export class App {
  // required and existing result
  existingEl = viewChild.required('requiredEl');
  // required but NOT existing result     
  missingEl = viewChild.required('notInATemplate');  
  
  ngAfterViewInit() {
    console.log(this.existingEl()); // OK :-)
    console.log(this.missingEl());  // Runtime error :-(
  }
}

Advanced Tips for Queries

The viewChild, viewChildren, contentChild, and contentChildren functions are all special functions that can only be used when declaring queries by initializing a component. You can never use these functions outside of a component and directives property initializers.

TypeScript
@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <div #el></div>
  `,
})
export class App {
  el = viewChild('el'); // all good!
  constructor() {
    const myConst = viewChild('el'); // NOT SUPPORTED
  }
}

While signal queries reduce or eliminate the use of ngAfterViewInit there are still template rendering timing concerns that you need to be aware of. Angular computes signal-based query results lazily, on demand. This means that the query results are not collected unless there is a code path that reads the signal.

That being said, if you try to access a signal query before query results can be collected, like before the template has rendered, then you will get undefined or empty array results.

Using Angular Signals with RxJS

As of Angular 18, Inputs as Signals is in Developer Preview.

really weird morphing animation of a man turning into a car

While Angular signals help simplify the asynchronous life-cycle of an app, there will still probably be teams that you need to use RxJS Observables. Angular has provided the @angular/core/rxjs-interop package specifically for this.

Converting an Observable to a Signal

You can use the toSignal function to track the values of an Observable similar to the async pipe in templates. toSignal will subscribe to the Oberservable immediately and will also handle unsubscribing when the parent is destroyed.

TypeScript
import { Component } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
  template: `{{ counter() }}`,
})
export class Ticker {
  counterObservable = interval(1000);
  // Get a `Signal` representing the `counterObservable`'s value.
  counter = toSignal(this.counterObservable, {initialValue: 0});
}

WARNING: toSignal creates a new subscription every time you call it. You should avoid calling it multiple times on the same observable and instead reuse the signal it returns.

In order to handle the unsubscribe to your Observable, you will need to provide an injection context. If you call toSignal in the constructor or as a component variable like in the example above, then you don’t need to do anything. However, if you need to call this later in your code, then you will need to inject a context.

TypeScript
@Component({
  template:`{{ counter() }}`,
})
export class FooComponent {
  counterObservable = interval(1000);
  counter: Signal<number | undefined>; 
  private injector = inject(Injector);

  ngOnInit() {
    this.counter = toSignal(this.counterObservable, { injector: this.injector } );
  }
}

If you do not want the signal to unsubscribe automatically, then you can specify to manually handle the unsubscribe with manualCleanup.

TypeScript
export class Ticker {
  counterObservable = interval(1000);
  // I will clean this up on my own time or create a memory leak.
  counter = toSignal(this.counterObservable, {manualCleanup: true});
}

When an Observable produces an error, then that error is thrown when the signal is read.

When an Observable completes, then the signal will continue to return the most recently emitted value from the Observable.

Converting Signals to Observables

Similar to toSignal, we have toObservable to convert signals to Observables. In the background Angular is using an effect to emit the values from the signal to the Observable when it changes. toObservable also requires an injection context similar to toSignal.

TypeScript
import { Component, signal } from '@angular/core';
@Component(...)
export class SearchResults {
  query: Signal<string> = inject(QueryService).query;
  query$ = toObservable(this.query);
  results$ = this.query$.pipe(
    switchMap(query => this.http.get('/search?q=' + query ))
  );
}

As query changes, the query$ Observable emits the latest change. query$ is then piped to do an HTTP request based on the query value.

Timing Issues when Converting Signals to Observables

toObservable uses an effect to track the value of a signal in a ReplaySubject. The first value (if available) is emitted synchronously and all values after are asynchronous.

Signals never notify of changes synchronously. This means that if you update a signal’s value multiple times, toObservable will only emit the value after the signal value has stabilized.

TypeScript
const obs$ = toObservable(mySignal);
obs$.subscribe(value => console.log(value));

mySignal.set(1);
mySignal.set(2);
// only this last value will be logged
mySignal.set(3);

Leave a Reply

I’m David

Welcome to my little corner of the internet that I dedicate to programming. I’m a professional web application developer and strive to always be learning new things. I love to code and I love to write about coding!

Let’s connect

Discover more from David Boothe's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading