A Deep Dive Into Angular Signals: The New Era of Reactivity

By LearnWebCraft Team20 min readIntermediate
Angular SignalsAngularReactivityState ManagementAngular 17

Alright, let's have a real talk. If you've been in the Angular trenches for a while, you've probably developed a certain... relationship with change detection. It's powered by Zone.js, this powerful, almost magical thing that just knows when your UI needs an update. And most of the time, it's fantastic. But sometimes, just sometimes, it feels like a bit of a black box, doesn't it? You poke something over here, and the whole component tree lights up over there. It works, but you don't always know exactly why.

And then there's RxJS. An absolute powerhouse for handling asynchronous events, don't get me wrong. But have you ever found yourself wrestling with a long chain of operators, async pipes, and subscriptions just to manage a simple boolean flag? Yeah, I've been there too. It can feel like you're using a sledgehammer to crack a nut.

What if there was a better way? A simpler, more explicit, and incredibly performant way to manage state and reactivity in our Angular apps?

Well, grab your favorite beverage, because that's exactly what Angular Signals are. This isn't just another feature—it's a fundamental shift in how Angular thinks about data flow. And honestly, after getting my hands on them, I can tell you it's one of the most exciting things to happen to the framework in years. Let's dive in and see what all the fuss is about.

So, What's the Big Deal with Angular Signals Anyway?

At its heart, a Signal is just a wrapper around a value. That's pretty much it. But this simple little wrapper has a superpower: it knows how to notify anyone who cares the instant its value changes. This simple concept unlocks a whole new world of possibilities.

Here’s why I’m genuinely excited about them, and why I think you will be too:

  • Fine-Grained Reactivity: This is the headline feature, the big one. Imagine your app is a building. With the old change detection, when one lightbulb changes, an inspector has to go and check every single room on that floor. With Angular Signals, the lightbulb that changed directly notifies the specific light switch that controls it. It's the difference between a broad, sweeping search and a laser-focused update. The performance implications here are just massive.

  • Automatic Dependency Tracking: Signals are like smart little assistants. You don't have to manually subscribe or tell them who's listening. When you read a signal's value inside a computed or an effect, it automatically notes, "Aha! This piece of code cares about me." The moment the signal's value changes, it knows exactly who to notify. No more forgotten unsubscribe() calls leading to pesky memory leaks. It just works.

  • A Simpler, More Intuitive API: The API for signals is refreshingly simple. If you've ever felt a little overwhelmed by the sheer number of RxJS operators, you'll find Signals to be a breath of fresh air for managing your synchronous state. It's easy to read, easy to write, and, most importantly, easy to reason about.

  • The Path to a Zoneless Future: Okay, this is the long-term vision, and it's a big one. Signals are the key that could unlock a future where Angular applications can run entirely without Zone.js. This would mean even better performance, smaller bundle sizes, and a much simpler mental model for how our apps actually work. Pretty exciting, right?

The Building Blocks: Your First Signal

Alright, let's get our hands dirty. The absolute core of this new world is the signal() function. You call it with an initial value, and it gives you back a reactive container for that value.

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

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div class="card">
      <h2>Simple Counter</h2>
      <p>Current Count: <span class="count-display">{{ count() }}</span></p>
      <div class="button-group">
        <button (click)="increment()">Increment +</button>
        <button (click)="decrement()">Decrement -</button>
        <button (click)="reset()">Reset</button>
      </div>
    </div>
  `,
  styles: [`
    .card { padding: 1.5em; border: 1px solid #ccc; border-radius: 8px; margin-top: 1em; text-align: center; }
    .count-display { font-weight: bold; font-size: 2em; color: var(--accent-color); }
    .button-group { margin-top: 1em; display: flex; justify-content: center; gap: 0.5em; }
    button { padding: 0.5em 1em; border-radius: 4px; border: none; background-color: var(--primary-color); color: white; cursor: pointer; }
    button:hover { opacity: 0.9; }
  `]
})
export class CounterComponent {
  // And here it is! Our first signal, initialized to 0.
  count = signal(0);

  increment() {
    // We'll explore how to update signals in a moment.
    this.count.update(currentValue => currentValue + 1);
  }

  decrement() {
    this.count.update(currentValue => currentValue - 1);
  }

  reset() {
    // And another way to update...
    this.count.set(0);
  }
}

The first thing you probably noticed is {{ count() }}. To get the value out of a signal, you call it like a function. It feels a little strange at first, I'll admit, but you get used to it surprisingly fast. This function call is the "magic" that lets Angular know you're accessing the signal's value, which is exactly how it builds its dependency tree behind the scenes.

Updating Signals: set(), update(), and mutate()

Okay, so you have a signal. How do you actually change its value? You've got three main tools in your belt.

  1. set(newValue): This is the most direct approach. It completely replaces the current value with a new one. Simple, effective, and does exactly what it says on the tin.

    const name = signal('Alice');
    console.log(name()); // Outputs: 'Alice'
    
    name.set('Bob'); // The value is now completely replaced.
    console.log(name()); // Outputs: 'Bob'
    
  2. update(updateFn): This one is my personal favorite, especially when the new value depends on the old one. It takes a function that receives the current value and returns the new value. It's absolutely perfect for immutable updates.

    const age = signal(30);
    // The function receives the current value (30) and returns the new one (31).
    age.update(currentAge => currentAge + 1);
    console.log(age()); // Outputs: 31
    

    Using update for a simple number might seem like overkill compared to set(age() + 1), but trust me—once you start working with objects and arrays, this pattern will save you a ton of headaches.

  3. mutate(mutatorFn): Now, this one is a bit of a special case. It's for those rare situations where you want to perform an internal mutation on the signal's value without replacing the object or array itself. This is less common, but it can be a handy performance optimization for large data structures where creating a new copy would be expensive. Just be sure to use this one with care!

    const user = signal({ name: 'Jane', roles: ['admin'] });
    
    user.mutate(currentUser => {
      // We're directly modifying the 'roles' array inside the signal.
      currentUser.roles.push('editor');
    });
    // The user object reference is the same, but its content has changed.
    

    Honestly, for 95% of cases, you'll be reaching for set and update. It's best to stick with them until you have a very specific reason not to.

Computed Signals: The Smart, Derived State

This is where Angular Signals really start to feel like magic. So often, you have a piece of state that is derived from other pieces of state. Think of a fullName that comes from a firstName and lastName, or a totalPrice from all the items in a shopping cart. This is exactly what computed() was made for.

A computed signal takes a function, and it automatically re-evaluates itself whenever any of the signals you read inside that function change. You don't have to tell it what to depend on; it just figures it out on its own. It's brilliant.

Let's build a small shopping cart to see it in action.

import { Component, signal, computed } from '@angular/core';

interface Product {
  name: string;
  price: number;
}

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    <div class="cart-card">
      <h3>Computed Signals Cart</h3>
      <p>Number of Items: <span class="highlight">{{ itemCount() }}</span></p>
      <p>Subtotal: <span class="highlight">\${{ subtotal() | number:'1.2-2' }}</span></p>
      <p>Tax (10%): <span class="highlight">\${{ tax() | number:'1.2-2' }}</span></p>
      <h4>Total: <span class="total-highlight">\${{ total() | number:'1.2-2' }}</span></h4>
      
      <button (click)="addItem()">Add Random Item</button>
      <button class="clear-btn" (click)="clearCart()">Clear Cart</button>
    </div>
  `,
  styles: [`
    .cart-card { /* styles... */ }
    .highlight { font-weight: bold; color: var(--accent-color); }
    .total-highlight { font-weight: bold; font-size: 1.2em; color: #28a745; }
    button { /* styles... */ }
  `]
})
export class CartComponent {
  items = signal<Product[]>([
    { name: 'Fancy Widget', price: 10.99 }
  ]);
  
  // A computed signal that depends on 'items'.
  subtotal = computed(() => 
    this.items().reduce((sum, item) => sum + item.price, 0)
  );

  // Another computed signal that depends on 'subtotal'.
  // Yes, you can chain them!
  tax = computed(() => this.subtotal() * 0.10);

  // And a final one that depends on 'subtotal' and 'tax'.
  total = computed(() => this.subtotal() + this.tax());

  // This one is also derived from 'items'.
  itemCount = computed(() => this.items().length);

  private availableItems = [
    { name: 'Cool Dongle', price: 12.00 },
    { name: 'Shiny Thing', price: 5.75 }
  ];

  addItem() {
    const newItem = this.availableItems[Math.floor(Math.random() * this.availableItems.length)];
    this.items.update(currentItems => [...currentItems, newItem]);
  }

  clearCart() {
    this.items.set([]);
  }
}

Just look at how clean that is! When we call addItem(), the items signal changes. The signal system then notifies subtotal and itemCount that they need to re-evaluate. Once subtotal has its new value, it in turn notifies tax and total. The whole cascade happens automatically, efficiently, and without us having to write a single line of glue code.

And the best part? Computed signals are lazy. They don't bother calculating their value until someone actually asks for it (like when it's read in the template). They're also glitch-free, which is a fancy way of saying they won't re-run multiple times if several of their dependencies change in the same go.

Effects: Running Side-Effects When Things Change

Okay, so we can manage state and derived state. That's great. But what happens when a signal change needs to trigger something in the "outside world"? Maybe you need to log something to the console, save data to localStorage, or call a third-party API.

That's precisely the job of effect().

An effect is a lot like a computed signal, but it doesn't return a value. Its whole purpose in life is to run a piece of code—a side-effect—in response to signal changes.

import { Component, signal, effect, Injector } from '@angular/core';

@Component({
  selector: 'app-logger',
  standalone: true,
  template: `
    <div class="logger-card">
      <h3>Effect Logger</h3>
      <label for="nameInput">Your Name:</label>
      <input id="nameInput" [value]="name()" (input)="updateName($event)" placeholder="Type here..." />
      <p class="status">_Status: <span class="highlight-status">{{ statusMessage() }}</span></p>
    </div>
  `,
  styles: [`
    .logger-card { /* styles... */ }
    .status { margin-top: 1em; }
    .highlight-status { font-weight: bold; }
  `]
})
export class LoggerComponent {
  name = signal('Jane');
  statusMessage = signal('Ready.');

  constructor(private injector: Injector) {
    // We create the effect inside the constructor.
    // Angular will automatically tie its lifecycle to the component's.
    effect(() => {
      const currentName = this.name(); // This read establishes the dependency!
      
      console.log(`Name changed to: ${currentName}`);
      localStorage.setItem('user_name', currentName);
      
      this.statusMessage.set(`Saved "${currentName}" to localStorage.`);
    });
  }

  updateName(event: Event) {
    this.name.set((event.target as HTMLInputElement).value);
  }
}

In this little example, whenever the name signal changes, the effect automatically runs. It logs the new name to the console and saves it to localStorage. It's simple, clean, and wonderfully declarative.

Now, here's a crucial rule of thumb: try your best not to update other signals inside an effect. Doing so can lead to infinite loops or some really confusing, unpredictable behavior. Think of effects as one-way streets: data flows from your signals out to the world.

Embracing Immutability: Signals with Arrays and Objects

When you're working with collections like arrays or objects inside a signal, the principle of immutability is your absolute best friend. Instead of modifying the array or object directly, you should always create a new one with the updated values. This is how Angular's change detection (and by extension, Signals) can efficiently know that something has actually changed.

The Classic Todo List Example

Let's build a todo list. It's the "Hello, World!" of reactive programming for a reason—it demonstrates adding, removing, and updating items in a collection perfectly.

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

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

@Component({
  selector: 'app-todos',
  standalone: true,
  // ... template and styles ...
  template: `
    <div class="todo-card">
      <h3>Signal Todos</h3>
      <input #todoInput (keyup.enter)="addTodo(todoInput.value); todoInput.value = ''" placeholder="What needs to be done?" />
      <ul>
        <li *ngFor="let todo of todos()" [class.completed]="todo.completed">
          <span (click)="toggleTodo(todo.id)">{{ todo.text }}</span>
          <button (click)="removeTodo(todo.id)">Delete</button>
        </li>
      </ul>
    </div>
  `
})
export class TodosComponent {
  todos = signal<Todo[]>([]);
  private nextId = 1;

  addTodo(text: string) {
    if (!text.trim()) return;
    
    // Use update to return a NEW array
    this.todos.update(currentTodos => [
      ...currentTodos, // Spread the existing todos
      { id: this.nextId++, text, completed: false } // Add the new one
    ]);
  }

  removeTodo(id: number) {
    // Use update to return a NEW, filtered array
    this.todos.update(currentTodos => 
      currentTodos.filter(todo => todo.id !== id)
    );
  }

  toggleTodo(id: number) {
    // Use update to return a NEW, mapped array
    this.todos.update(currentTodos =>
      currentTodos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }
}

Are you noticing a pattern here? ...currentTodos, .filter(), .map(). All of these operations create a brand new array. This is the key. We never, ever do something like this.todos().push(newTodo). That would be mutating the array in place, and the signal would have no idea that it needs to notify everyone who depends on it.

Signals vs. RxJS Observables: The Right Tool for the Job

So, does this mean we throw away all our RxJS code? Absolutely not.

Think of it like having a toolbox. You have a hammer and a screwdriver. Both are incredibly useful, but you wouldn't use a hammer to turn a screw. Signals and Observables are specialized tools designed for different jobs.

Use Angular Signals when:

  • You're managing simple, synchronous state within a component (things like isMenuOpen, currentUser, or a list of items).
  • You need to derive state from other state (like a fullName from a firstName and lastName).
  • You want those fine-grained, super-performant updates to the DOM.

Stick with RxJS Observables when:

  • You're dealing with asynchronous operations (like HTTP requests, WebSockets, or timers).
  • You're handling streams of events that happen over time (like keyboard inputs, mouse movements, or router events).
  • You need the awesome power of complex operators like debounceTime, switchMap, combineLatest, retry, and so on.

The golden rule I've started to live by is this: If it's a value that is, it's a signal. If it's a stream of events that happen over time, it's an Observable.

Bridging the Worlds: Seamless Interoperability

The Angular team knew we'd be living in a world with both Signals and Observables for a long time, so they gave us some powerful tools to convert between them.

From Observable to Signal: toSignal()

This is incredibly useful for taking an asynchronous stream, like an HTTP response, and turning its latest emission into a signal that's easy to use in your template.

import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { startWith, catchError, of } from 'rxjs';

@Component({
  selector: 'app-user-profile',
  standalone: true,
  // ... template and styles ...
})
export class UserProfileComponent {
  // Fetch a user from an API
  private user$ = this.http.get<any>('https://jsonplaceholder.typicode.com/users/1').pipe(
    catchError(err => {
      console.error('Failed to fetch user', err);
      return of({ name: 'Error Loading User' }); // Return a fallback on error
    })
  );

  // Convert the Observable to a Signal!
  // We provide an initialValue for the signal to have before the observable emits.
  user = toSignal(this.user$, { initialValue: { name: 'Loading...' } });

  constructor(private http: HttpClient) {}
}

No more | async pipe or manual subscriptions in the component! toSignal handles all the subscription and unsubscription logic for you, and gives you a simple, readable signal in your template: {{ user().name }}. It's so clean.

From Signal to Observable: toObservable()

And, of course, you can go the other way, too. This is perfect for when you have a signal (like a search term from an input) that you want to feed into a powerful RxJS pipeline.

import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs';

@Component({
  selector: 'app-live-search',
  standalone: true,
  // ... template and styles ...
})
export class LiveSearchComponent {
  searchTerm = signal('');
  isLoading = signal(false);

  // Convert the searchTerm signal into an Observable
  private searchResults$ = toObservable(this.searchTerm).pipe(
    debounceTime(300), // Wait for the user to stop typing
    distinctUntilChanged(), // Only search if the term has changed
    tap(() => this.isLoading.set(true)), // Set loading state
    switchMap(term => this.searchService.search(term)), // Call the search API
    tap(() => this.isLoading.set(false)) // Unset loading state
  );

  // Convert the results back to a signal for the template!
  searchResults = toSignal(this.searchResults$, { initialValue: [] });
  
  constructor(private searchService: SearchService) {}

  onInput(event: Event) {
    this.searchTerm.set((event.target as HTMLInputElement).value);
  }
}

Here, we really get the best of both worlds. We have a simple signal managing our input's state, and a powerful RxJS pipeline to handle the complex asynchronous logic of a live search. It's a perfect partnership.

The Future is Now: Signal-Based Inputs and Queries

With Angular 17 and beyond, the revolution continued. Signals are now being integrated into the very fabric of our components.

Signal Inputs: A Game-Changer

Gone are the days of the @Input() decorator and the ngOnChanges lifecycle hook. Component inputs can now be signals directly. This is a huge simplification and makes our components so much more reactive.

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

@Component({
  selector: 'app-avatar',
  standalone: true,
  template: `
    <div class="avatar" [style.background-color]="bgColor()">
      {{ initials() }}
    </div>
  `,
  styles: [` .avatar { /* styles... */ } `]
})
export class AvatarComponent {
  // A required string input. The parent MUST provide this.
  name = input.required<string>();
  
  // An optional input with a default value.
  bgColor = input<string>('#cccccc');

  // We can create a computed signal based on an input!
  initials = computed(() => {
    const names = this.name().split(' ');
    if (names.length > 1) {
      return `${names[0][0]}${names[names.length - 1][0]}`.toUpperCase();
    }
    return this.name().substring(0, 2).toUpperCase();
  });
}

Using it in a parent component is exactly as you'd expect: <app-avatar name="John Doe" bgColor="lightblue" />

This is just so much cleaner. Inputs are reactive right out of the box, and you can easily use computed or effect to react to their changes without any of the old lifecycle hook boilerplate.

Signal Queries: Reactive DOM References

Similarly, viewChild and viewChildren now have signal-based versions, giving you a reactive way to get references to elements or components in your template.

import { Component, signal, viewChild, effect, ElementRef } from '@angular/core';

@Component({
  selector: 'app-carousel',
  standalone: true,
  template: `
    <div #carouselContainer>...</div>
  `
})
export class CarouselComponent {
  // Query for the #carouselContainer element.
  // It returns a Signal<ElementRef | undefined>.
  container = viewChild<ElementRef>('carouselContainer');

  constructor() {
    effect(() => {
      const el = this.container();
      if (el) {
        // The element is now in the DOM! We can initialize our carousel library.
        console.log('Container is available:', el.nativeElement);
        // new SomeCarouselLibrary(el.nativeElement);
      } else {
        // The element has been removed from the DOM.
        console.log('Container has been removed.');
      }
    });
  }
}

The effect will run automatically whenever the element becomes available or is removed from the DOM (say, from an *ngIf). This is incredibly powerful for setting up and tearing down third-party libraries that need a direct DOM reference.

A Signal-Based Service: Clean, Centralized State

One of my favorite patterns to emerge from this new world is using signals for state management right inside an injectable service. For many applications, this can completely replace the need for a full-blown state management library like NgRx or Akita.

The key is to expose public, read-only signals to the rest of the app, while keeping the actual, mutable signals private to the service.

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartService {
  // The private, writable signal. This is our source of truth.
  private items = signal<CartItem[]>([]);

  // Public, read-only signals for components to consume.
  readonly cartItems = this.items.asReadonly();
  readonly itemCount = computed(() => this.items().length);
  readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));

  // --- Public methods to mutate the state ---
  
  addItem(item: CartItem) {
    this.items.update(current => [...current, item]);
  }

  removeItem(itemId: number) {
    this.items.update(current => current.filter(i => i.id !== itemId));
  }
  
  clearCart() {
    this.items.set([]);
  }
}

By using asReadonly(), we create a beautiful, predictable pattern. Components can inject this service and read from cart.cartItems() or cart.total(), but they have no way to change the state directly. The only way to modify the cart is by calling the public methods like cart.addItem(). This enforces a clear, one-way data flow that is so much easier to debug and maintain.

Wrapping Up: A New Way of Thinking

Angular Signals are more than just a new API; they're an invitation to think about reactivity in a more direct and intuitive way. They offer a path to simpler components, incredible performance gains, and more maintainable state management.

The transition doesn't have to happen overnight. Start by using signals in a few new components. Use the interop functions to bridge the gap with your existing RxJS code. I think you'll quickly find that they simplify your code and make you a more productive and happier Angular developer.

The future of Angular is reactive, fine-grained, and incredibly fast. And Signals are leading the charge. Go ahead, give them a try. I have a feeling you're going to love them.


FAQ: Your Burning Questions About Angular Signals

Q: Do Angular Signals completely replace RxJS?

A: Not at all! Think of them as two different tools for two different jobs. Signals are fantastic for synchronous state management and derived values. RxJS is still the king of handling complex asynchronous events and data streams over time. They are designed to work together beautifully.

Q: Are Signals only available in the latest version of Angular?

A: Signals were introduced in Angular v16 and became stable and ready for prime-time in v17. The newer signal-based features like input() and viewChild() were introduced in v17.1 and later. To get the full, amazing experience, you'll want to be on the latest version of Angular.

Q: What's the main performance benefit of using Signals?

A: The biggest win is definitely fine-grained reactivity. Instead of re-checking an entire component tree when something changes, Angular can update only the specific, tiny part of the DOM that depends on a signal that changed. This is much more efficient and leads to faster, smoother applications, especially as they get more complex.

Q: How do I handle side-effects like API calls when a signal changes?

A: Ah, that's a bit of a trick question! You generally shouldn't trigger an API call from within an effect(). An effect is for reacting to state changes that have already happened. For state that comes from an API, you should use an Observable (e.g., from HttpClient) and convert its result to a signal using toSignal(). If an action (like a button click) needs to trigger a refetch, that action should be what triggers the Observable to run again.

Q: Can I use Signals in my existing, large Angular application?

A: Yes, absolutely! Signals are designed for gradual adoption. You can start using them in new components today without having to touch your old ones. The interoperability functions, toSignal() and toObservable(), are your best friends for making your new signal-based code talk to your existing observable-based code.

Related Articles