2. Core Concepts: Embracing Signals
Angular Signals are a game-changer for reactive state management in Angular applications. Introduced experimentally in Angular 16 and stable in Angular 20, Signals provide a simpler, more performant, and explicit way to manage application state without the boilerplate often associated with RxJS. They fundamentally change how reactivity works in Angular, moving towards a more fine-grained and predictable change detection.
What are Angular Signals?
A Signal is a wrapper around a value that notifies interested consumers when that value changes. Think of it as a special kind of observable, but simpler and with a focus on synchronous value changes and automatic dependency tracking.
The core idea is that when a signal’s value changes, any code that reads that signal will automatically be re-executed, but only the parts that depend on it. This leads to extremely efficient and granular updates, improving performance significantly.
There are three main types of signals:
- Writable Signals: The most basic type, allowing you to directly update their value.
- Computed Signals: Read-only signals whose value is derived from one or more other signals. They automatically recompute only when their dependencies change.
- Effect Signals: Operations that run whenever one or more signal values they depend on change. Effects are typically used for side effects, like logging, updating the DOM directly (carefully!), or interacting with browser APIs.
Why Signals? The Evolution of Reactivity
Before Signals, Angular’s primary reactivity model relied on Zone.js and RxJS Observables.
- Zone.js: Automatically detects asynchronous operations (like HTTP requests,
setTimeout, DOM events) and triggers Angular’s change detection cycle across the entire component tree. While convenient, this could be inefficient, leading to unnecessary re-renders and making debugging harder. - RxJS Observables: Provided a powerful way to handle asynchronous data streams, but often came with boilerplate (subscribing, unsubscribing) and a different mental model that could be challenging for beginners.
Signals offer a more direct, explicit, and performant alternative:
- Granular Change Detection: Only components or templates that directly depend on a changed signal will update, leading to much better performance.
- Reduced Boilerplate: No more manual subscriptions and unsubscriptions for simple state management.
- Explicit Reactivity: You explicitly define reactive dependencies, making it easier to understand how changes propagate.
- Zoneless Compatibility: Signals are a perfect fit for a future where Angular applications can run without Zone.js, giving developers even more control over change detection.
Writable Signals: The Foundation
A writable signal is created using the signal() function and holds a value that can be directly modified.
Detailed Explanation
You create a writable signal by calling signal() with its initial value.
import { signal } from '@angular/core';
// Create a signal with an initial value of 0
const count = signal(0);
To read the value of a signal, you call it like a function:
console.log(count()); // Outputs: 0
To update the value of a writable signal, you use its set() or update() methods:
set(newValue): Replaces the current value withnewValue.update(updaterFn): Takes an updater function, which receives the current value and returns the new value. This is useful for incremental changes.mutate(mutatorFn): Takes a mutator function, which receives the current value (a mutable object) and mutates it directly. Use with caution, as it can bypass immutability benefits if not handled carefully.update()is generally preferred for simple values.
Code Examples
Let’s see writable signals in action within an Angular component.
// src/app/counter/counter.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; // Needed for standalone components
@Component({
selector: 'app-counter',
standalone: true, // Marking as standalone
imports: [CommonModule],
template: `
<h2>Counter: {{ count() }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
<h3>User Name: {{ userName() }}</h3>
<button (click)="changeUserName()">Change User Name</button>
`,
styles: `
button {
margin-right: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
}
h2, h3 {
color: #333;
}
`
})
export class CounterComponent {
// 1. Declare a writable signal for a number
count = signal(0);
// 2. Declare a writable signal for a string
userName = signal('Alice');
constructor() {
// You can read signals in the constructor, but usually done in templates or effects
console.log('Initial count:', this.count());
console.log('Initial user name:', this.userName());
}
// Method to increment the count
increment() {
this.count.update(currentCount => currentCount + 1);
console.log('Incremented to:', this.count());
}
// Method to decrement the count
decrement() {
this.count.update(currentCount => currentCount - 1);
console.log('Decremented to:', this.count());
}
// Method to reset the count
reset() {
this.count.set(0); // Using set() to directly set the value
console.log('Reset to:', this.count());
}
// Method to change the user name
changeUserName() {
this.userName.set('Bob Smith');
console.log('User name changed to:', this.userName());
}
}
To make this component visible, you’d typically import it into your AppComponent or another routing component if you’re using standalone components.
// src/app/app.component.ts
import { Component } from '@angular/core';
import { CounterComponent } from './counter/counter.component'; // Import the new component
@Component({
selector: 'app-root',
standalone: true,
imports: [CounterComponent], // Add CounterComponent to imports
template: `
<h1>My Angular App</h1>
<app-counter></app-counter> <!-- Use the CounterComponent -->
`,
})
export class AppComponent {
title = 'my-angular-app';
}
Now, run ng serve and observe how the count() and userName() values in the template update instantly when the buttons are clicked, thanks to Angular’s efficient signal-based change detection.
Exercises/Mini-Challenges: Writable Signals
Boolean Toggle:
- Create a component called
ToggleButtonComponent. - Inside this component, declare a writable signal named
isToggledinitialized tofalse. - In the template, display “ON” or “OFF” based on the
isToggledsignal’s value. - Add a button that, when clicked, toggles the value of
isToggled(i.e., if it’strue, make itfalse, and vice-versa). - Add a second button to explicitly set
isToggledtotrue. - Hint: Use
update()for toggling andset()for explicit setting.
- Create a component called
Input Field with Signal:
- Modify
ToggleButtonComponent(or create a newTextInputComponent). - Declare a writable signal named
inputValueinitialized to an empty string. - In the template, create an HTML
<input type="text">element. - Use Angular’s
(input)event binding to update theinputValuesignal whenever the user types. - Display the current
inputValuebelow the input field in real-time. - Hint: The
(input)event provides the event object, from which you can getevent.target.value.
- Modify
Computed Signals: Derived State
Computed signals are read-only signals that derive their value from one or more other signals. They are extremely efficient because they only recompute when their underlying dependencies change, and Angular automatically handles the dependency tracking.
Detailed Explanation
You create a computed signal using the computed() function. It takes a “computation function” as an argument. Inside this function, you read other signals, and computed() automatically subscribes to these dependencies.
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// Computed signal for full name
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // Outputs: John Doe
firstName.set('Jane');
console.log(fullName()); // Outputs: Jane Doe (recomputed automatically)
The computation function for a computed signal runs only when one of its dependencies changes. If you access a computed signal multiple times without its dependencies changing, the cached value is returned immediately without re-execution of the computation function.
Code Examples
Let’s extend our CounterComponent to include computed signals.
// src/app/counter/counter.component.ts
import { Component, signal, computed } from '@angular/core'; // Import 'computed'
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-counter',
standalone: true,
imports: [CommonModule],
template: `
<h2>Counter: {{ count() }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
<p>Is Count Even: <strong>{{ isCountEven() ? 'Yes' : 'No' }}</strong></p>
<p>
Current Status:
<strong [style.color]="statusColor()">{{ statusText() }}</strong>
</p>
<h3>Product: {{ productName() }} - {{ productPrice() | currency }}</h3>
<p>Total Price (incl. tax): {{ totalPriceWithTax() | currency }}</p>
<button (click)="changeProductPrice()">Change Product Price</button>
`,
styles: `
button {
margin-right: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
}
p {
font-size: 1.1em;
}
`
})
export class CounterComponent {
count = signal(0);
productName = signal('Laptop');
productPrice = signal(1200);
taxRate = signal(0.05); // 5% tax
constructor() {
console.log('Initial count:', this.count());
}
// 1. Computed signal: checks if the count is even
isCountEven = computed(() => {
console.log('Recalculating isCountEven...'); // Observe when this runs
return this.count() % 2 === 0;
});
// 2. Computed signal: provides a status text based on count
statusText = computed(() => {
console.log('Recalculating statusText...'); // Observe when this runs
const currentCount = this.count();
if (currentCount > 5) {
return 'High Count';
} else if (currentCount < 0) {
return 'Negative Count';
} else {
return 'Normal Count';
}
});
// 3. Computed signal: provides a color for the status text
statusColor = computed(() => {
console.log('Recalculating statusColor...'); // Observe when this runs
const currentStatus = this.statusText(); // This also triggers statusText to recompute if needed
if (currentStatus === 'High Count') {
return 'red';
} else if (currentStatus === 'Negative Count') {
return 'orange';
} else {
return 'green';
}
});
// 4. Computed signal: calculates total price with tax
totalPriceWithTax = computed(() => {
console.log('Recalculating totalPriceWithTax...'); // Observe when this runs
const price = this.productPrice();
const rate = this.taxRate();
return price * (1 + rate);
});
increment() {
this.count.update(currentCount => currentCount + 1);
}
decrement() {
this.count.update(currentCount => currentCount - 1);
}
reset() {
this.count.set(0);
}
changeProductPrice() {
this.productPrice.set(this.productPrice() + 50);
}
}
Notice the console.log statements within the computed functions. Open your browser’s developer console and interact with the buttons. You’ll see that isCountEven recomputes only when count changes, and statusText and statusColor also recompute intelligently based on their dependencies.
Exercises/Mini-Challenges: Computed Signals
Shopping Cart Total:
- Create a new component
ShoppingCartComponent. - Declare two writable signals:
itemPrice(e.g.,signal(10)) andquantity(e.g.,signal(2)). - Create a computed signal
subtotalthat calculatesitemPrice * quantity. - Add a writable signal
discountPercentage(e.g.,signal(0.1)for 10% discount). - Create another computed signal
finalPricethat applies thediscountPercentageto thesubtotal. - In the template, display all these values. Add buttons to increment/decrement
quantityand changeitemPrice. - Challenge: Add an input field to allow the user to change the
discountPercentagedynamically.
- Create a new component
Form Validity Indicator:
- Building on the
TextInputComponentfrom the previous exercise (or a newSimpleFormComponent). - Declare a writable signal
emailInput(e.g.,signal('')). - Create a computed signal
isEmailValidthat returnstrueifemailInputcontains “@” and has a length greater than 5, otherwisefalse. - Display a message like “Email is valid” or “Email is invalid” based on
isEmailValid. - Challenge: Add another input for
passwordInputand create a computed signalisFormValidthat checks if bothemailInputandpasswordInputmeet certain criteria (e.g., password length > 8).
- Building on the
Effect Signals: Side Effects
Effects are operations that are triggered whenever one or more signal values they depend on change. They are typically used for side effects that need to interact with non-signal-based parts of your application or browser APIs.
Detailed Explanation
You create an effect using the effect() function. It takes an “effect function” as an argument. Any signals read inside the effect function will be tracked as dependencies. When one of these dependencies changes, the effect function will be re-executed.
import { signal, effect } from '@angular/core';
const count = signal(0);
// An effect that logs the count whenever it changes
effect(() => {
console.log(`The current count is: ${count()}`);
});
count.set(1); // Output: The current count is: 1
count.set(2); // Output: The current count is: 2
Important considerations for effects:
- Non-reactive context: Effects run in a non-reactive context, meaning you shouldn’t modify other signals directly within an effect unless you explicitly opt-out of reactivity tracking for that modification (e.g., by calling
untracked()). Directly modifying signals inside an effect withoutuntracked()can lead to infinite loops if the modified signal is also a dependency of the same effect. - Cleanup: Effects can return a cleanup function, which is called before the effect re-runs or when the effect is destroyed (e.g., when the component it belongs to is destroyed). This is useful for cleaning up event listeners or subscriptions.
- Injection Context: Effects require an injection context. You typically create them in a component’s constructor or
ngOnInitmethod, where theInjectoris available. - Destruction: Effects are automatically destroyed when the component (or the
EnvironmentInjectorthey are associated with) is destroyed.
Code Examples
Let’s add effects to our CounterComponent for logging and DOM manipulation (carefully!).
// src/app/counter/counter.component.ts
import { Component, signal, computed, effect, ElementRef, ViewChild, Injector } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common'; // Import CurrencyPipe
@Component({
selector: 'app-counter',
standalone: true,
imports: [CommonModule, CurrencyPipe], // Add CurrencyPipe to imports
template: `
<h2>Counter: {{ count() }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
<p>Is Count Even: <strong>{{ isCountEven() ? 'Yes' : 'No' }}</strong></p>
<p>
Current Status:
<strong [style.color]="statusColor()">{{ statusText() }}</strong>
</p>
<h3>Product: {{ productName() }} - {{ productPrice() | currency }}</h3>
<p>Total Price (incl. tax): {{ totalPriceWithTax() | currency }}</p>
<button (click)="changeProductPrice()">Change Product Price</button>
<hr>
<h4>Effect Demonstrations</h4>
<div #logArea style="border: 1px solid #ccc; padding: 10px; min-height: 50px; margin-top: 10px;">
<!-- This area will be updated by an effect -->
</div>
`,
styles: `
button {
margin-right: 8px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
}
p {
font-size: 1.1em;
}
`
})
export class CounterComponent {
count = signal(0);
productName = signal('Laptop');
productPrice = signal(1200);
taxRate = signal(0.05);
// ViewChild to get a reference to the log area div
@ViewChild('logArea') logArea!: ElementRef<HTMLDivElement>;
// Computed signals (from previous example)
isCountEven = computed(() => this.count() % 2 === 0);
statusText = computed(() => {
const currentCount = this.count();
if (currentCount > 5) return 'High Count';
else if (currentCount < 0) return 'Negative Count';
else return 'Normal Count';
});
statusColor = computed(() => {
const currentStatus = this.statusText();
if (currentStatus === 'High Count') return 'red';
else if (currentStatus === 'Negative Count') return 'orange';
else return 'green';
});
totalPriceWithTax = computed(() => this.productPrice() * (1 + this.taxRate()));
private logEffect: any; // Store the effect for potential cleanup (though auto-destroyed with component)
constructor(private injector: Injector) {
// 1. Basic logging effect: runs whenever `count` changes
effect(() => {
console.log(`[Effect 1] Count has changed to: ${this.count()}`);
});
// 2. Effect that interacts with the DOM (after view init)
// Needs to be run inside an injection context (e.g., constructor)
this.logEffect = effect(() => {
// Accessing a signal here will make this effect rerun when it changes
const currentCount = this.count();
// To ensure logArea is available, we typically defer DOM interaction
// until after initial view render, or use ngAfterViewInit
// For this example, we'll assume it's there or handle it carefully.
if (this.logArea) {
this.logArea.nativeElement.innerHTML +=
`<p>Count updated in DOM effect: <strong>${currentCount}</strong> at ${new Date().toLocaleTimeString()}</p>`;
// Scroll to bottom
this.logArea.nativeElement.scrollTop = this.logArea.nativeElement.scrollHeight;
}
}, { injector: this.injector }); // Provide injector if not in component/directive constructor
}
// Lifecycle hook for initial DOM element availability
ngAfterViewInit() {
// Re-trigger the effect if needed to ensure it runs with `logArea` initialized
// Or, more robustly, structure your effect to handle `logArea` being initially undefined.
// For simplicity, this example directly accesses it assuming it's present.
}
increment() {
this.count.update(currentCount => currentCount + 1);
}
decrement() {
this.count.update(currentCount => currentCount - 1);
}
reset() {
this.count.set(0);
}
changeProductPrice() {
this.productPrice.set(this.productPrice() + 50);
}
}
Now, when you run this, you’ll see console logs and updates directly within the logArea div in the browser, all driven by the count signal changing.
Exercises/Mini-Challenges: Effect Signals
LocalStorage Persistence:
- Create a component
SettingsComponent. - Declare a writable signal
theme(e.g.,signal('light')). - Implement an
effectthat, wheneverthemechanges, saves the new theme value tolocalStorage. - In the
SettingsComponent’s constructor, initialize thethemesignal by reading fromlocalStorageif a value exists. - Add buttons in the template to switch between ’light’ and ‘dark’ themes.
- Hint: Use
localStorage.setItem('theme', this.theme())andlocalStorage.getItem('theme'). Remember to handlenullif no theme is found inlocalStorage.
- Create a component
Document Title Updater:
- In a component (e.g.,
AppComponent), declare a writable signalpageTitle(e.g.,signal('Home Page')). - Create an
effectthat updates the browser’s document title (document.title) wheneverpageTitlechanges. - Add an input field and a button to allow the user to change the
pageTitledynamically. Observe the browser tab title changing.
- In a component (e.g.,
Combining Signals with Inputs and Outputs (model(), output())
Modern Angular (Angular 17+) introduces a new way to handle component inputs and outputs, which integrates seamlessly with Signals and provides clearer two-way data binding.
Detailed Explanation
model()for Bidirectional Input: Themodel()function creates a special type of input that acts like a signal. It allows a parent component to pass a value to a child and for the child to update that value, with the changes automatically flowing back to the parent. This is the modern replacement for[(ngModel)]style two-way binding.A
model()input behaves like a signal: you read its value by calling it and update it using.set(),.update(), or.mutate().output()for Events: Theoutput()function simplifies emitting events from child components to parent components. It returns an object with anemit()method.
Code Examples
Let’s create a child component that uses model() and output(), and a parent component to interact with it.
// src/app/editable-label/editable-label.component.ts
import { Component, model, output, ElementRef, ViewChild, OnChanges, SimpleChanges, OnInit, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; // Needed for ngModel in inputs if used, but for model() we use signal approach
@Component({
selector: 'app-editable-label',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="editable-container">
@if (!isEditing()) {
<span (click)="startEditing()" class="display-text">{{ text() }}</span>
<button (click)="startEditing()">Edit</button>
} @else {
<input #editInput
type="text"
[value]="text()"
(input)="text.set($event.target.value)"
(blur)="stopEditing()"
(keyup.enter)="stopEditing()"
/>
<button (click)="stopEditing()">Save</button>
}
</div>
`,
styles: `
.editable-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.display-text {
cursor: pointer;
padding: 5px 0;
border-bottom: 1px dashed #ccc;
}
input {
padding: 5px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
}
button {
padding: 5px 10px;
cursor: pointer;
}
`
})
export class EditableLabelComponent implements OnInit {
// 1. Bidirectional input using `model()`
// Parent can pass a 'text' signal, and child can update it.
text = model<string>('Default Editable Text');
// 2. Writable signal for internal component state
isEditing = signal(false);
// Use input() for a read-only input that doesn't trigger two-way binding
labelId = input<string | undefined>(undefined);
@ViewChild('editInput') editInput!: ElementRef<HTMLInputElement>;
ngOnInit(): void {
if (this.labelId()) {
console.log(`EditableLabel component with ID: ${this.labelId()} initialized.`);
}
}
startEditing() {
this.isEditing.set(true);
// Use setTimeout to allow DOM to render the input before focusing
setTimeout(() => {
this.editInput.nativeElement.focus();
});
}
stopEditing() {
this.isEditing.set(false);
console.log(`Label ID: ${this.labelId() ?? 'N/A'}, New value: ${this.text()}`);
// No explicit emit needed for `text` as `model()` handles it.
}
}
Now, let’s use this EditableLabelComponent in AppComponent.
// src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { CounterComponent } from './counter/counter.component';
import { EditableLabelComponent } from './editable-label/editable-label.component'; // Import EditableLabelComponent
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
CounterComponent,
EditableLabelComponent, // Add EditableLabelComponent
],
template: `
<main style="padding: 20px;">
<h1>My Angular App with Signals!</h1>
<h2>Simple Counter</h2>
<app-counter></app-counter>
<hr>
<h2>Editable Labels (using `model()` for two-way binding)</h2>
<p>Parent Text 1: {{ parentText1() }}</p>
<app-editable-label [(text)]="parentText1" labelId="label-1"></app-editable-label>
<p>Parent Text 2: {{ parentText2() }}</p>
<app-editable-label [(text)]="parentText2" labelId="label-2"></app-editable-label>
<hr>
<h2>One-way Input (using `input()`)</h2>
<p>This label shows a static message from parent: {{ staticMessage() }}</p>
<app-editable-label [text]="staticMessage()" labelId="static-label"></app-editable-label>
<p>
<em>Note: When using `[text]` (one-way binding), the child's changes to `text` won't
propagate back to `staticMessage` unless explicitly handled, which is
the expected behavior for `input()` based props, or for `model()` when only
the input part is used. To allow the child to update the parent, you MUST use `[(text)]`
or `[text]` combined with `(textChange)` if `model()` is structured to emit.
In our `model()` implementation, the `text.set()` handles the update directly.</em>
</p>
</main>
`,
styles: []
})
export class AppComponent {
title = 'my-angular-app';
parentText1 = signal('Hello Angular World!');
parentText2 = signal('Edit me too!');
staticMessage = signal('This is a static message.');
// The `text` model input handles both getting and setting the value.
// So `(textChange)` is implicitly handled when `text.set()` is called in the child.
// The syntax `[(text)]="parentText1"` automatically unwraps and re-wraps the signal
// for the two-way binding.
}
When you run this application:
- Edit “Hello Angular World!” in the first
EditableLabelComponent. You’ll see “Parent Text 1” update instantly. - Edit “Edit me too!” in the second
EditableLabelComponent. “Parent Text 2” will update. - Try editing the third
EditableLabelComponent. You’ll see the internal component state change, but “This is a static message.” from the parent will not update. This demonstrates the difference betweenmodel()(two-way binding with[(text)]) andinput()(one-way binding with[text]). Formodel()to work correctly in a one-way binding scenario where the child can still emit, it exposes atext.set()which modifies the parent’s signal directly.
Exercises/Mini-Challenges: model() and output()
Star Rating Component:
- Create a
StarRatingComponent. - It should have a
model<number>input forrating(default to 0). - Display 5 stars (e.g.,
⭐or☆). When a user clicks on a star, set theratingto that star’s index (1-5). - The parent component should display the current
ratingand pass a signal to theStarRatingComponent. - Challenge: Add a read-only
input<boolean>fordisabledto prevent changing the rating whentrue.
- Create a
Toggle Switch with Label:
- Create a
ToggleSwitchComponent. - It should have a
model<boolean>input forchecked(default tofalse). - It should also have a read-only
input<string>forlabelText. - In the template, display the
labelTextand a simple toggle switch UI (e.g., a button that changes text “ON”/“OFF” or a CSS-styled switch). - Clicking the toggle should update the
checkedsignal. - The parent component should use two instances of
ToggleSwitchComponent, each with a differentlabelTextand a different signal forchecked.
- Create a
By mastering writable, computed, and effect signals, along with the modern model() and output() for component communication, you are well-equipped to manage state effectively and build highly performant Angular applications with much less boilerplate. In the next chapter, we will delve into how these signals are enabling Angular to move towards a zoneless future.