Core Concepts: Embracing Signals

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:

  1. Writable Signals: The most basic type, allowing you to directly update their value.
  2. Computed Signals: Read-only signals whose value is derived from one or more other signals. They automatically recompute only when their dependencies change.
  3. 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 with newValue.
  • 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

  1. Boolean Toggle:

    • Create a component called ToggleButtonComponent.
    • Inside this component, declare a writable signal named isToggled initialized to false.
    • In the template, display “ON” or “OFF” based on the isToggled signal’s value.
    • Add a button that, when clicked, toggles the value of isToggled (i.e., if it’s true, make it false, and vice-versa).
    • Add a second button to explicitly set isToggled to true.
    • Hint: Use update() for toggling and set() for explicit setting.
  2. Input Field with Signal:

    • Modify ToggleButtonComponent (or create a new TextInputComponent).
    • Declare a writable signal named inputValue initialized to an empty string.
    • In the template, create an HTML <input type="text"> element.
    • Use Angular’s (input) event binding to update the inputValue signal whenever the user types.
    • Display the current inputValue below the input field in real-time.
    • Hint: The (input) event provides the event object, from which you can get event.target.value.

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

  1. Shopping Cart Total:

    • Create a new component ShoppingCartComponent.
    • Declare two writable signals: itemPrice (e.g., signal(10)) and quantity (e.g., signal(2)).
    • Create a computed signal subtotal that calculates itemPrice * quantity.
    • Add a writable signal discountPercentage (e.g., signal(0.1) for 10% discount).
    • Create another computed signal finalPrice that applies the discountPercentage to the subtotal.
    • In the template, display all these values. Add buttons to increment/decrement quantity and change itemPrice.
    • Challenge: Add an input field to allow the user to change the discountPercentage dynamically.
  2. Form Validity Indicator:

    • Building on the TextInputComponent from the previous exercise (or a new SimpleFormComponent).
    • Declare a writable signal emailInput (e.g., signal('')).
    • Create a computed signal isEmailValid that returns true if emailInput contains “@” and has a length greater than 5, otherwise false.
    • Display a message like “Email is valid” or “Email is invalid” based on isEmailValid.
    • Challenge: Add another input for passwordInput and create a computed signal isFormValid that checks if both emailInput and passwordInput meet certain criteria (e.g., password length > 8).

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 without untracked() 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 ngOnInit method, where the Injector is available.
  • Destruction: Effects are automatically destroyed when the component (or the EnvironmentInjector they 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

  1. LocalStorage Persistence:

    • Create a component SettingsComponent.
    • Declare a writable signal theme (e.g., signal('light')).
    • Implement an effect that, whenever theme changes, saves the new theme value to localStorage.
    • In the SettingsComponent’s constructor, initialize the theme signal by reading from localStorage if a value exists.
    • Add buttons in the template to switch between ’light’ and ‘dark’ themes.
    • Hint: Use localStorage.setItem('theme', this.theme()) and localStorage.getItem('theme'). Remember to handle null if no theme is found in localStorage.
  2. Document Title Updater:

    • In a component (e.g., AppComponent), declare a writable signal pageTitle (e.g., signal('Home Page')).
    • Create an effect that updates the browser’s document title (document.title) whenever pageTitle changes.
    • Add an input field and a button to allow the user to change the pageTitle dynamically. Observe the browser tab title changing.

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: The model() 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: The output() function simplifies emitting events from child components to parent components. It returns an object with an emit() 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:

  1. Edit “Hello Angular World!” in the first EditableLabelComponent. You’ll see “Parent Text 1” update instantly.
  2. Edit “Edit me too!” in the second EditableLabelComponent. “Parent Text 2” will update.
  3. 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 between model() (two-way binding with [(text)]) and input() (one-way binding with [text]). For model() to work correctly in a one-way binding scenario where the child can still emit, it exposes a text.set() which modifies the parent’s signal directly.

Exercises/Mini-Challenges: model() and output()

  1. Star Rating Component:

    • Create a StarRatingComponent.
    • It should have a model<number> input for rating (default to 0).
    • Display 5 stars (e.g., or ). When a user clicks on a star, set the rating to that star’s index (1-5).
    • The parent component should display the current rating and pass a signal to the StarRatingComponent.
    • Challenge: Add a read-only input<boolean> for disabled to prevent changing the rating when true.
  2. Toggle Switch with Label:

    • Create a ToggleSwitchComponent.
    • It should have a model<boolean> input for checked (default to false).
    • It should also have a read-only input<string> for labelText.
    • In the template, display the labelText and 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 checked signal.
    • The parent component should use two instances of ToggleSwitchComponent, each with a different labelText and a different signal for checked.

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.