All articles
Development TipsSoftware Architecture

Angular Signals: A Complete Guide to Reactive State Management

What Are Signals?

Angular Signals are a new reactive primitive introduced in Angular 16 that fundamentally change how we think about state management and reactivity. They provide fine-grained reactivity with better performance and developer experience than traditional RxJS patterns.

Why Signals Matter

  • Simpler Mental Model - No more subscription management headaches
  • Better Performance - Precise change detection, less re-rendering
  • Less Boilerplate - No need for async pipes or manual subscriptions
  • Type Safety - Full TypeScript support with inference
  • Improved DX - Easier debugging and testing

Core Concepts

Creating Signals

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

// Simple signal
const count = signal(0);

// Complex signal
const user = signal<User>({ name: 'John', email: 'john@example.com' });

// Reading values
console.log(count());  // 0
console.log(user().name);  // 'John'

// Updating values
count.set(5);
user.set({ name: 'Jane', email: 'jane@example.com' });

// Updating with previous value
count.update(value => value + 1);

Computed Signals

import { computed } from '@angular/core';

const firstName = signal('John');
const lastName = signal('Doe');

// Automatically updates when dependencies change
const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log(fullName());  // 'John Doe'

firstName.set('Jane');
console.log(fullName());  // 'Jane Doe' (automatically updated!)

Effects

import { effect } from '@angular/core';

// Runs whenever dependencies change
effect(() => {
  console.log(`Count is now: ${count()}`);

  // Can perform side effects
  localStorage.setItem('count', count().toString());
});

Practical Examples

Shopping Cart Component

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

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

@Component({
  selector: 'app-shopping-cart',
  template: `
    <div class="cart">
      <h2>Shopping Cart</h2>

      @for (item of items(); track item.id) {
        <div class="cart-item">
          <span>{{ item.name }}</span>
          <span>${{ item.price }}</span>
          <button (click)="updateQuantity(item.id, -1)">-</button>
          <span>{{ item.quantity }}</span>
          <button (click)="updateQuantity(item.id, 1)">+</button>
          <button (click)="removeItem(item.id)">Remove</button>
        </div>
      }

      <div class="cart-total">
        <strong>Total Items: {{ totalItems() }}</strong>
        <strong>Total Price: ${{ totalPrice() }}</strong>
      </div>
    </div>
  `
})
export class ShoppingCartComponent {
  items = signal<CartItem[]>([]);

  // Computed values automatically update
  totalItems = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );

  totalPrice = computed(() =>
    this.items().reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );

  addItem(item: Omit<CartItem, 'quantity'>) {
    this.items.update(items => [...items, { ...item, quantity: 1 }]);
  }

  updateQuantity(id: string, change: number) {
    this.items.update(items =>
      items.map(item =>
        item.id === id
          ? { ...item, quantity: Math.max(0, item.quantity + change) }
          : item
      ).filter(item => item.quantity > 0)
    );
  }

  removeItem(id: string) {
    this.items.update(items => items.filter(item => item.id !== id));
  }
}

Search with Debounce

import { Component, signal, effect } from '@angular/core';
import { debounceTime } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  template: `
    <input
      [value]="searchQuery()"
      (input)="searchQuery.set($event.target.value)"
      placeholder="Search..."
    />

    @if (loading()) {
      <div>Loading...</div>
    } @else {
      @for (result of searchResults(); track result.id) {
        <div class="result">{{ result.title }}</div>
      }
    }
  `
})
export class SearchComponent {
  searchQuery = signal('');
  searchResults = signal<SearchResult[]>([]);
  loading = signal(false);

  constructor(private searchService: SearchService) {
    // Effect runs when searchQuery changes
    effect(() => {
      const query = this.searchQuery();

      if (!query) {
        this.searchResults.set([]);
        return;
      }

      this.loading.set(true);

      // Use RxJS for debouncing (signals + RxJS work great together!)
      this.searchService.search(query)
        .pipe(debounceTime(300))
        .subscribe(results => {
          this.searchResults.set(results);
          this.loading.set(false);
        });
    });
  }
}

Signals vs RxJS: When to Use What

Use Signals For:

  • Component state management
  • Derived/computed values
  • UI state (loading, errors, form values)
  • Simple event handlers
  • Local component logic

Use RxJS For:

  • HTTP requests and async operations
  • Complex event streams
  • Operators like debounce, throttle, retry
  • Multiple async sources to combine
  • Global state management patterns

Best Pattern: Combine Both!

// Service with RxJS
@Injectable()
export class UserService {
  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users');
  }
}

// Component with Signals
@Component({...})
export class UsersComponent {
  users = signal<User[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);

  constructor(private userService: UserService) {
    this.loadUsers();
  }

  loadUsers() {
    this.loading.set(true);
    this.error.set(null);

    this.userService.getUsers().subscribe({
      next: users => {
        this.users.set(users);
        this.loading.set(false);
      },
      error: err => {
        this.error.set(err.message);
        this.loading.set(false);
      }
    });
  }
}

Common Patterns

Form State Management

@Component({...})
export class LoginFormComponent {
  email = signal('');
  password = signal('');

  isValid = computed(() =>
    this.email().includes('@') && this.password().length >= 8
  );

  onSubmit() {
    if (this.isValid()) {
      this.authService.login(this.email(), this.password());
    }
  }
}

Pagination State

@Component({...})
export class DataTableComponent {
  currentPage = signal(1);
  pageSize = signal(10);
  allData = signal<Item[]>([]);

  paginatedData = computed(() => {
    const start = (this.currentPage() - 1) * this.pageSize();
    const end = start + this.pageSize();
    return this.allData().slice(start, end);
  });

  totalPages = computed(() =>
    Math.ceil(this.allData().length / this.pageSize())
  );

  nextPage() {
    this.currentPage.update(page =>
      Math.min(page + 1, this.totalPages())
    );
  }

  prevPage() {
    this.currentPage.update(page => Math.max(page - 1, 1));
  }
}

Migration from RxJS

Before (RxJS)

export class CounterComponent {
  private countSubject = new BehaviorSubject(0);
  count$ = this.countSubject.asObservable();

  doubleCount$ = this.count$.pipe(
    map(count => count * 2)
  );

  increment() {
    this.countSubject.next(this.countSubject.value + 1);
  }
}

// Template
<div>Count: {{ count$ | async }}</div>
<div>Double: {{ doubleCount$ | async }}</div>

After (Signals)

export class CounterComponent {
  count = signal(0);
  doubleCount = computed(() => this.count() * 2);

  increment() {
    this.count.update(c => c + 1);
  }
}

// Template
<div>Count: {{ count() }}</div>
<div>Double: {{ doubleCount() }}</div>

Performance Benefits

Signals enable OnPush change detection by default, significantly improving performance:

  • Precise Updates - Only components using changed signals re-render
  • No Zone.js - Future Angular versions can be zoneless
  • Less Memory - No subscription cleanup needed
  • Faster Initial Render - Simpler change detection graph

Best Practices

  1. Keep signals local - Use services for shared state
  2. Don't overuse computed - Only for derived values
  3. Use effects sparingly - Prefer computed for transformations
  4. Immutable updates - Always return new objects/arrays
  5. Type your signals - Leverage TypeScript for safety

Conclusion

Angular Signals represent a major evolution in Angular's reactivity model. They're simpler, faster, and more intuitive than traditional patterns. Start using them in new components, and gradually migrate existing code where it makes sense.

At Studio X Consulting, we're building all new Angular applications with signals-first architecture. The developer experience and performance improvements are dramatic. Ready to modernize your Angular app? Let's talk about how signals can improve your codebase.

Learn more at www.studioxconsulting.com. Contact Studio X Consulting to discuss legacy modernization, AI-assisted development, and delivery.

Keep reading

Related articles