TL;DR: Angular Signals offers a synchronous and efficient way to manage state changes, leading to more responsive UIs. It’s a significant step toward a faster and more developer-friendly Angular framework.
Angular has long been known for its powerful reactive tools, particularly its use of Observables, which efficiently handle asynchronous data and event streams. However, the recent addition, Angular Signals, introduces a new approach to reactivity that simplifies synchronization between the application state and the UI.
Angular signals offers a fresh, synchronous way to manage state changes that enhances UI performance by targeting only the elements affected by data updates. This makes Signals particularly useful for optimizing change detection, reducing unnecessary updates, and allowing developers to maintain a more responsive interface.
In this blog, we’ll explore Angular Signals, how they differ from Observables, and how they integrate into Angular’s existing ecosystem. We’ll also cover practical use cases and how to leverage signals to build efficient, reactive apps.
What is reactive programming?
Reactive programming is a programming paradigm designed to create software that automatically reacts to changes in data and events. Instead of waiting for user actions like clicks or keyboard inputs, reactive systems respond dynamically to shifts in the data they manage. This approach enables apps to remain responsive and up-to-date in real-time, providing smoother user experiences.
In the context of Angular, reactive programming refers to updating a component’s template in response to changes in its underlying data, such as data in the component’s class or services. For example, if a component’s data is updated via a service, Angular’s reactive programming principles ensure the view is automatically re-rendered to reflect this new data. By using reactive programming, Angular apps can maintain high responsiveness and efficiency, making them more intuitive and user-friendly.
How does reactivity work in an Angular app?
In Angular, reactivity is handled primarily through zone.js, a library that enables Angular to detect when a change has occurred in the app. However, this detection is somewhat limited. Although zone.js identifies that something has changed, it does not specify what changed, where the change occurred, or how it impacts different parts of the app. As a result, Angular is only aware that some change has occurred without further details about which specific elements or parts of the view are affected.
To manage these changes, Angular initiates a process called change detection. During this process, it traverses the entire component and view tree, checking all data bindings to see if anything has been updated. This approach ensures that all changes are captured but can be inefficient, as Angular will check every binding—even those that have not changed—resulting in unnecessary processing.
To improve performance, Angular offers the OnPush change detection strategy, limiting change detection to only those components with specific triggers, such as updated input properties or observable data streams. OnPush allows Angular to skip parts of the view tree that haven’t changed, optimizing the process. However, many components are still subject to change detection, which can create overhead in larger apps.
Additionally, in development mode, Angular runs change detection twice, aiming to detect unintended side effects from data updates. While helpful for identifying bugs, double-checking adds to the processing load, potentially impacting development efficiency. This approach highlights both the strengths and limitations of Angular’s reactive system, emphasizing the need for optimizations like OnPush to maintain performance as apps grow in complexity.
Goals for a better Angular framework
To improve Angular’s performance and developer experience, several goals guide the evolution of the framework:
- Efficient UI updates: A core goal is to achieve more efficient UI updates by syncing only the parts of the interface that require changes. This means targeting updates down to individual components rather than running change detection across the entire view hierarchy. Such precision would significantly enhance performance, particularly in complex apps with multiple components.
- Zoneless applications: Removing reliance on zone.js is another objective, aiming to eliminate the overhead and occasional quirks associated with it. Without zone.js, Angular can streamline its update detection and reduce dependencies, leading to lighter and more maintainable apps.
- Reducing bundle size by removing zone.js: By eliminating zone.js, Angular can shrink the main bundle size by up to 100 KB, which can make a noticeable difference in load times. This reduction is particularly valuable for performance on mobile devices and for users with slower internet connections.
- Simplified lifecycle hooks and component queries: Angular seeks to simplify its lifecycle hooks and component queries, making them easier to understand and work with. This would reduce the learning curve for developers and improve productivity, especially for those new to the framework.
- Native async/await support: Another goal is to enable Angular to handle async and await operations natively without converting them into promises. This change would bypass the limitations of zone.js and allow Angular to operate more smoothly with modern JavaScript features, leading to more efficient and readable asynchronous code.
These improvements aim to make Angular a faster, more efficient, and developer-friendly framework that can handle the growing demands of modern web apps.
What are Angular signals?
Angular signals are a new reactive primitive introduced in Angular 16 that provides a powerful and efficient way to manage state and reactivity in your apps. Angular signals introduce a new way of managing reactivity in apps, focusing on precision and efficiency. A signal is essentially a wrapper around a value that notifies any parts of the app relying on it whenever that value changes. Unlike other reactive constructs, a signal always holds a value, which can be of any type—ranging from simple data like numbers or strings to complex objects or arrays.
One key characteristic of signals is that they are side-effect-free; reading a signal does not trigger any other code or create unintended changes. This makes signals highly predictable and safe to use, as developers can trust that accessing a signal won’t accidentally alter different parts of the app. Signals track changes with precision, updating only the parts of the UI directly affected, in contrast to the zone.js approach where Angular runs change detection across the entire component tree to capture updates.
As of Angular 18, the change detection system still depends on zone.js, which limits the immediate performance benefits of signals. Although using signals doesn’t yet provide a substantial speed increase compared to the existing zone.js-based reactivity, signals represent an important step forward. They set the stage for zoneless app in the future, paving the way for a more efficient, streamlined approach to managing reactivity in Angular.
Angular signals offer three main types of reactive constructs:
- Writable signals
- Computed signals
- Effects
Each type serves a different purpose in managing data reactivity within an Angular app. Let’s explore them in detail.
Writable signals
These fundamental, mutable signals hold a value and allow for updates. Writable signals act as a state container that can store any kind of data—whether it’s a number, string, object, or array. Developers can create a writable signal, set an initial value, and update it as needed. When the value of a writable signal changes, it automatically notifies every part of the UI that depends on it, causing only those parts to update. Signals are getter functions; calling them will return the current value of the signal.
Angular has two primary methods for changing a signal’s value: set() and update(). Each serves a unique purpose:
- set(): This method allows you to assign a new value to the signal directly, effectively replacing the previous value. set() is ideal when you want to overwrite the existing value with a new one and don’t need to reference the current state of the signal.
- update(): The update() method is used when the new value depends on the current value of the signal. It allows you to compute and set a new value based on the previous state, making it useful for operations like incrementing, decrementing, or performing calculations based on the existing value.
Example:
import { signal } from '@angular/core;
// Writable signal initialized with a value of 0.
const counter = signal(0);
// Signals are getter functions, calling them will return the current value of the signal.
console.log('Count is: ', counter()); // 0
// Set the value of a signal.
counter.set(3);
// Update the value of a signal.
counter.update((value) => value + 1);
Computed signals
Computed signals are read-only signals that derive their values from other signals, making them perfect for calculations or transformations that depend on existing state. Computed signals are created using the computed function, which defines a derivation based on one or more dependent signals.
Here’s a breakdown of their key characteristics:
- Read-only: Computed signals are inherently read-only and cannot be directly assigned a new value. Instead, they update automatically based on the values of their dependencies. This makes them highly reliable for reflecting real-time changes without needing manual intervention.
- Side-effect-free: The function used in a computed signal should only read values from other signals without causing any changes or side effects. This helps keep the computation predictable and prevents unintended alterations elsewhere in the app.
- Automatic updates: Whenever a dependent signal changes, the computed signal re-evaluates and updates itself. This automatic behavior allows computed signals to stay current with the underlying data while minimizing the need for extra code to handle updates.
For example, we can use a computed signal to double the value of a writable signal, as shown in the following code.
import { signal, computed } from '@angular/core';
// Writable signal with an initial value of 5.
const counter = signal(5);
// Automatically doubles the value.
// doubleCounter will be 10, and it updates automatically if the counter changes.
const doubleCounter = computed(() => counter() * 2);
In this example, doubleCounter depends on the counter signal. If the counter changes, doubleCounter will automatically recalculate its value without any direct assignment, providing a streamlined, reactive way to derive values in Angular apps. Computed signals are powerful for keeping derived data in sync and are integral to Angular’s approach to precise, efficient reactivity.
Effects
Effects allow developers to respond to changes in signals by invoking side effects, like making API requests, logging, or updating the DOM. Effects are created using the effect function and automatically rerun whenever any of their dependent signals change.
Here’s a deeper look at their key characteristics:
- Always run at least once: An effect executes immediately upon creation to ensure it responds to the initial values of its tracked signals. It will then rerun whenever one or more dependent signals are updated, ensuring the effect remains in sync with changing data.
- Dynamic dependency tracking: Like computed signals, effects use dynamic dependency tracking. This means that any signals read during the effect’s execution become dependencies. If those signals change in the future, the effect automatically reruns, keeping side effects up-to-date without manually resubscribing.
- Asynchronous execution: Effects run as part of Angular’s change detection process, which enables smooth integration with Angular’s reactive data flow. Asynchronous tasks within effects, such as API calls or timeouts, can be managed naturally within the framework, avoiding unexpected delays or race conditions.
- Automatic cleanup: When an effect is tied to a component, directive, or service, it is automatically destroyed when the context is removed from the DOM. This cleanup helps prevent memory leaks by ensuring effects do not persist after their context is no longer active.
Suppose we have a userId signal and an effect that fetches user data every time when the userId changes:
import { signal, effect } from '@angular/core;
const userId = signal(1);
effect(() => {
console.log(`Fetching data for user ID: ${userId()}`);
// Make an API call or any other side effect here.
});
In this example, the effect will log a message whenever userId is updated. The dynamic dependency tracking allows the effect to rerun automatically for each change in userId, ensuring the side effect stays in sync with the latest data.
Effects are invaluable for performing tasks that interact with the outside world or rely on asynchronous operations. They provide a clean, reactive approach to handling side effects in Angular.
When to use effects
While effects in Angular signals may not be necessary for every app, they shine in specific scenarios where side effects need to be managed in response to reactive state changes. Here are some instances where using impacts can be particularly beneficial:
- Logging data: Effects can log changes in signals, providing a straightforward way to track app state over time.
- Syncing with local storage: Effects are ideal for keeping an app state in sync with localStorage or other persistent storage solutions. Whenever a signal changes, an effect can be triggered to update the corresponding value in local storage, ensuring data is preserved across sessions.
- Custom DOM behavior: In some cases, developers may need to implement custom behavior for elements in the DOM that are not natively handled by Angular. Effects can be used to manipulate the DOM directly based on signal changes, allowing for dynamic updates without relying solely on Angular’s templating system.
- Custom rendering: Effects can facilitate integration with third-party UI libraries or rendering tools, such as elements, charts, or graphics libraries. When signals that control rendering parameters change, effects can rerender these visual elements, providing a seamless and dynamic user experience.
When not to use effects
While effects offer valuable functionality in Angular signals, there are certain scenarios where their use should be avoided to maintain a stable and efficient app. Here are key considerations for when not to use effects:
- State change propagation: Effects should not be used to propagate state changes across signals. Doing so can lead to common pitfalls, such as ExpressionChangedAfterItHasBeenChecked errors, which occur when Angular detects that a value has changed after it has already run change detection. This can create confusion in the app flow and result in unstable behavior.
- Infinite circular updates: Using effects to trigger updates in signals that depend on each other can create infinite loops. For example, suppose an effect modifies a signal that, in turn, triggers the same effect. In that case, it can result in an endless cycle of updates, causing performance issues and potentially crashing the app.
- Restrictions on signal writes: By default, Angular prevents setting signals within effects to avoid the previously mentioned issues. While it is possible to override this restriction using the allowSignalWrites flag, it is advised only in special cases where the developer is confident in managing the potential complexities. Misusing this feature can easily lead to the problems associated with state change propagation.
- Using computed signals instead: For a state that depends on other signals, it is generally better to use computed signals rather than effects. Computed signals are designed to manage derived values efficiently, ensuring that they automatically update in response to changes in their dependencies. This approach provides a smoother and more predictable reactivity model without the complexities that effects might introduce.
Signals versus observables
Signals and observables serve different purposes in managing data flow within an Angular app. Signals are a synchronous mechanism that Angular uses to manage and synchronize the app state with the UI. Their synchronous nature makes them ideal for handling real-time state changes predictably. When a signal’s value changes, it automatically updates the associated UI components. This approach enhances Angular’s change detection by updating only the parts of the UI that need it, making state management more efficient and responsive.
On the other hand, observables are asynchronous, making them highly suited for tasks that involve waiting for external resources or handling delayed responses, like fetching data from APIs. By design, observables can emit multiple values over time, making them perfect for handling streams of data or events asynchronously. This capability is especially useful when handling user interactions or data that updates at unpredictable intervals.
With Angular signals utility functions, developers can integrate signals and observables within the same app. These utilities enable developers to harness the synchronous nature of signals alongside the asynchronous advantages of observables. This integration provides a streamlined way to leverage the strengths of both approaches, allowing for flexible and reactive programming tailored to specific application needs.
Converting observables to signals
Angular provides a convenient way to convert observables into signals using the toSignal function. This function allows developers to harness the reactivity of observables in a more flexible way than the traditional async pipe. Unlike the async pipe, which is limited to template use, toSignal can be applied anywhere in an app, giving developers greater control over where and how reactive data is handled.
The toSignal function begins by subscribing to the observable immediately. This immediate subscription means that any emissions from the observable trigger updates in the signal’s value right away. However, developers should be mindful that this immediate subscription can also initiate side effects, such as HTTP requests or other operations defined in the observable.
Another essential aspect of toSignal is its built-in cleanup mechanism. When a component or service using toSignal is destroyed, Angular automatically unsubscribes from the observable. This automatic cleanup prevents potential memory leaks by ensuring that unused subscriptions are closed, which is particularly helpful in large or complex apps where managing subscriptions manually can be challenging. By using toSignal, developers can create reactive and efficient apps while maintaining clean and manageable code.
Example:
import { toSignal } from '@angular/core/rxjs-interop;
private readonly httpClient = inject(HttpClient);
private products$ = this.httpClient.get<ProductResponse>('api/endpoint/');
// Get a `Signal` representing the `products$`'s value.
productList = toSignal(this.products$);
Converting signals to observables
Angular provides a toObservable function to convert signals into observables, making it easy to leverage signals in reactive contexts where observables are required. This conversion is particularly useful when you need to work with other libraries or components that expect data in the form of an observable.
The toObservable function operates by monitoring the signal’s value through an effect. Whenever the signal’s value changes, toObservable captures these changes and emits them as observable values. However, to prevent unnecessary emissions, toObservable only emits the final, stabilized value when a signal updates multiple times in quick succession. This ensures that only meaningful changes are propagated, keeping the observable stream efficient and avoiding redundant processing.
Example:
fruitList = signal(['apple', 'orange', 'grapes']);
fruitList$: Observable<string[]> = toObservable(this.fruitList);
A sample app using Angular signals
Let’s understand the concept of signals using a real-world app. We will create a mini version of an e-commerce app.
Run the following command to create an Angular app.
ng new signals-demo
Run the following command and follow the on-screen instructions to set up Angular Material for your project.
ng add @angular/material
Add a new folder named model in the src\app folder. Create a new file called product.ts inside the models folder and add the following code.
export interface Product {
id: number;
title: string;
images: string[];
price: number;
}
export interface ProductResponse {
limit: number;
skip: number;
total: number;
products: Product[];
}
Add a file called shopping-cart.ts inside the models folder and put the following code inside it.
import { Product } from './product';
export interface ShoppingCart {
product: Product;
quantity: number;
}
Add a new service file using the following command.
ng g s services\product
Update the ProductService class in the \app\services\product.service.ts file, as shown.
export class ProductService {
private readonly httpClient = inject(HttpClient);
private products$ = this.httpClient.get<ProductResponse>(
'https://dummyjson.com/products?limit=8'
);
cartItemsList = signal<Product[]>([]);
productList = toSignal(this.products$);
}
The cartItemsList is a public property that holds the signal of an empty array of Product objects. The productList is a public property with a signal converted from the products$ observable.
The service fetches a list of products from a publicly available API and provides reactive signals for both the product and cart items lists.
Create a new component using the following command.
ng g c components\product-list
Update the ProductListComponent class in the src\app\components\product-list\product-list.component.ts file as follows.
export class ProductListComponent {
private readonly productService = inject(ProductService);
protected productList = this.productService.productList;
addItemToCart(id: number) {
const selectedItem = this.productList()?.products.find(
(product) => product.id === id
);
this.productService.cartItemsList.update((cartItems) => {
if (selectedItem) cartItems?.push(selectedItem);
return cartItems;
});
}
}
The ProductListComponent is responsible for displaying a list of products and allowing users to add products to their cart. The addItemToCart function searches for a product with a specific ID in the product list. On finding the product, it adds it to the cart items list and updates it to include the newly added product.
Add the following code in the src\app\components\product-list\product-list.component.html file.
<div class="row">
<div class="col d-flex justify-content-start mb-4 flex-wrap my-2">
@for (product of productList()?.products; track $index) {
<mat-card class="product-card m-2" appearance="outlined">
<img
class="preview-image"
mat-card-image
src="{{ product.images[0] }}"
alt="product image"
/>
<mat-card-content>
<p>{{ product.title }}</p>
</mat-card-content>
<mat-card-actions class="mt-2" align="end">
<button mat-flat-button (click)="addItemToCart(product.id)">
Add to Cart
</button>
</mat-card-actions>
</mat-card>
}
</div>
</div>
This template renders a list of products in a responsive grid layout using Angular Material cards. Each card displays a product image, title, and an Add to Cart button. The @for directive loops through the products and dynamically generates the cards.
Create the cart component using the following command.
ng g c components\cart
Update the CartComponent class in the src\app\components\cart\cart.component.ts file as shown:
export class CartComponent {
private readonly productService = inject(ProductService);
displayedColumns: string[] = ['name', 'quantity', 'price'];
protected finalCartItems = computed(() => {
const shoppingCart: ShoppingCart[] = [];
this.productService.cartItemsList().map((item) => {
const index = shoppingCart.findIndex(
(finalCart) => item.id === finalCart.product.id
);
if (index > -1) {
shoppingCart[index].quantity += 1;
} else {
shoppingCart.push({ product: item, quantity: 1 });
}
});
return shoppingCart;
});
protected totalCost = computed(() => {
return this.finalCartItems().reduce((acc, item) => {
return acc + item.product.price * item.quantity;
}, 0);
});
}
The finalCartItems computed signal aggregates the cart items by combining quantities for items with the same ID. Meanwhile, the totalCost computed signal calculates the total cost of the items in the cart by summing the product of the price and quantity for each item.
Add the following code in the src\app\components\cart\cart.component.html file.
<div class="row d-flex justify-content-center">
<div class="col-md-10 my-4">
@if(finalCartItems().length > 0){
<mat-card>
<mat-card-content>
<table class="mat-display-1" mat-table [dataSource]="finalCartItems()">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let element">
{{ element.product.title }}
</td>
<td mat-footer-cell *matFooterCellDef></td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef>Quantity</th>
<td mat-cell *matCellDef="let element">{{ element.quantity }}</td>
<td mat-footer-cell *matFooterCellDef>Total Cost</td>
</ng-container>
<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef>Price</th>
<td mat-cell *matCellDef="let element">
{{ element.product.price | currency }}
</td>
<td mat-footer-cell *matFooterCellDef>
<strong>{{ totalCost() | currency }}</strong>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
<tr mat-footer-row *matFooterRowDef="displayedColumns"></tr>
</table>
</mat-card-content>
</mat-card>
}@else {
<h3>No data found</h3>
}
</div>
</div>
This template renders a shopping cart using Angular Material components. It conditionally displays a table of cart items if there are any items in the cart, showing the product name, quantity, and price. If the cart is empty, it displays a No data found message. The table includes header, data, and footer rows, with the footer displaying the total cost of the items in the cart.
Create the nav-bar component using the following command.
ng g c components\nav-bar
Update the NavBarComponent class in the src\app\components\nav-bar\nav-bar.component.ts file:
export class NavBarComponent {
private readonly productService = inject(ProductService);
cartItemsList = this.productService.cartItemsList;
}
Add the following code in the src\app\components\nav-bar\nav-bar.component.html file.
<mat-toolbar>
<button mat-button [routerLink]="['/']">
<mat-icon aria-hidden="false" fontIcon="home_filled"></mat-icon>
Home
</button>
<span class="spacer"></span>
<button
mat-icon-button
matBadge="{{ cartItemsList().length }}"
matBadgeSize="large"
class="me-4"
>
<mat-icon [routerLink]="['/cart']">shopping_cart </mat-icon>
</button>
</mat-toolbar>
This template creates a navigation bar with home and cart buttons. The home button navigates to the home page and displays a home icon. The cart button shows a shopping cart icon and a badge with the number of items in the cart.
Update the routing in the app.route.ts file.
import { Routes } from '@angular/router';
import { ProductListComponent } from './components/product-list/product-list.component';
import { CartComponent } from './components/cart/cart.component';
export const routes: Routes = [
{ path: '', component: ProductListComponent },
{ path: 'cart', component: CartComponent },
];
Update the src\app\app.component.html file as shown.
<app-nav-bar></app-nav-bar>
<div class="container">
<router-outlet></router-outlet>
</div>
Finally, run the app using the command ng serve. You can see the output here.
Summary
Angular signals bring simplicity and precision to reactive programming in Angular apps. By providing a synchronous, efficient way to manage state, signals allow developers to update the UI only when necessary, improving performance by minimizing redundant change detection cycles. Unlike observables, which are ideal for handling asynchronous data streams, signals excel in synchronizing app state with the UI, making them a powerful addition for managing real-time data in a responsive interface.
Throughout this blog, we explored how signals fit into Angular’s reactive ecosystem, the differences between signals and observables, and when to use each approach. We also discussed practical use cases for signals, such as working with computed values and effects and integrating signals with observables for a flexible and efficient reactivity model.
With Angular signals, developers now have greater control over app reactivity, setting a solid foundation for zoneless apps and more streamlined change detection in the future. As Angular continues to evolve, mastering signals will become essential for building high-performance, modern apps.