TL;DR: Angular Signals answer the question "Do I really need to learn RxJS just to build a counter?" With signal(), computed(), and effect(), you get reactive state management that feels like plain TypeScript. Simple, type-safe, and performant.

🤔 Why Do We Need Signals?

Be honest: who hasn't spent half an hour debugging a BehaviorSubject, only to realize you forgot to unsubscribe()? RxJS is powerful - no doubt about it. But for many everyday tasks in Angular components, it's like bringing a bazooka to a water gun fight.

Angular recognized this and introduced Signals with version 16. The idea is simple: reactive state management that you can understand without first earning a PhD in marble diagrams.

Here's the exciting part: Signals aren't competing with RxJS. They complement it. But for component-local state, they're an absolute game changer.

Angular – The Modern Web Development Framework
Angular is a platform for building mobile and desktop web applications. Built by Google, it provides a comprehensive solution for frontend development.
RxJS – Reactive Extensions Library for JavaScript
RxJS is a library for reactive programming using Observables, making it easier to compose asynchronous or callback-based code.

⚙️ The Basics: signal(), computed(), and effect()

signal() - Your Reactive Container

A Signal is essentially a wrapper around a value that automatically tells Angular when something changes. Sounds simple - because it is.

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

// Create a signal
const count = signal(0);

// Read the value
console.log(count()); // 0

// Set the value
count.set(42);
console.log(count()); // 42

// Update based on current value
count.update(current => current + 1);
console.log(count()); // 43

You'll notice: no subscribe(), no pipe(), no Observable. You just call count() and get the current value. Done. Angular automatically tracks where you read the signal and only updates the affected parts of the template.

computed() - Derived Values

When you have a value that depends on other signals, computed() comes into play. Think of it like an Excel formula: when an input changes, the result is automatically recalculated.

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

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

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

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

firstName.set('Jane');
console.log(fullName()); // "Jane Doe"

The beauty of it: computed() is lazy. The value is only calculated when it's actually read. And it's cached - if the dependencies haven't changed, it won't recalculate. Free performance.

effect() - Side Effects That React to Changes

effect() is for everything that should happen when a signal changes - logging, API calls, localStorage updates, you name it.

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

const theme = signal('light');

// Automatically runs when theme changes
effect(() => {
  document.body.classList.toggle('dark-mode', theme() === 'dark');
  console.log(`Theme switched to: ${theme()}`);
});

// Triggers the effect
theme.set('dark');
// Console: "Theme switched to: dark"

Important: effects run in the injection context. That means you typically create them in the constructor or as field initializers of your component. Angular automatically handles cleanup when the component is destroyed. No ngOnDestroy needed.

RxJS: The Reactive Revolution in JavaScript
RxJS brings reactive programming to JavaScript. Learn how Observables and operators work.

🔄 RxJS vs. Signals - When to Use Which?

Here's the crux of the matter. Signals and RxJS aren't enemies - they have different strengths.

Use Case Signals ✅ RxJS ✅
Component-local state ⭐ Perfect Overkill
Form validation ⭐ Simple & direct Works, but verbose
HTTP requests With resource() ⭐ HttpClient is RxJS-based
WebSocket streams Not suited ⭐ Built for this
Complex event chains Not suited ⭐ switchMap, debounceTime & co
Global app state ⭐ With services Also works
UI toggles/counters ⭐ Trivial Unnecessarily complex

Rule of thumb: If you have a value that changes and you want the UI to react → Signal. If you're dealing with streams, timing, or complex event chains → RxJS.

💻 Practical Example: Todo App with Signals

Enough theory. Let's build a small todo component that shows how signals look in practice.

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

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

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h2>My Todos ({{ openCount() }} open)</h2>

    <input [(ngModel)]="newTodoText"
           (keyup.enter)="addTodo()"
           placeholder="New todo..." />
    <button (click)="addTodo()">Add</button>

    <ul>
      @for (todo of filteredTodos(); track todo.id) {
        <li [class.done]="todo.done">
          <input type="checkbox"
                 [checked]="todo.done"
                 (change)="toggleTodo(todo.id)" />
          {{ todo.text }}
          <button (click)="removeTodo(todo.id)">🗑️</button>
        </li>
      }
    </ul>

    <div class="filters">
      <button (click)="filter.set('all')">All</button>
      <button (click)="filter.set('open')">Open</button>
      <button (click)="filter.set('done')">Done</button>
    </div>
  `
})
export class TodoComponent {
  // State as signals
  todos = signal<Todo[]>([]);
  filter = signal<'all' | 'open' | 'done'>('all');
  newTodoText = '';
  private nextId = 1;

  // Computed: filtered todos
  filteredTodos = computed(() => {
    const currentFilter = this.filter();
    const allTodos = this.todos();

    switch (currentFilter) {
      case 'open': return allTodos.filter(t => !t.done);
      case 'done': return allTodos.filter(t => t.done);
      default: return allTodos;
    }
  });

  // Computed: open todo count
  openCount = computed(() =>
    this.todos().filter(t => !t.done).length
  );

  addTodo() {
    if (!this.newTodoText.trim()) return;
    this.todos.update(todos => [
      ...todos,
      { id: this.nextId++, text: this.newTodoText.trim(), done: false }
    ]);
    this.newTodoText = '';
  }

  toggleTodo(id: number) {
    this.todos.update(todos =>
      todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
  }

  removeTodo(id: number) {
    this.todos.update(todos => todos.filter(t => t.id !== id));
  }
}

Look at how clean that is. Not a single subscribe(), no async pipe, no memory leak danger. State lives in signals, derived values in computed(), and Angular automatically knows what needs re-rendering.

🚀 linkedSignal() and resource() - The New APIs

Angular keeps evolving, and newer versions bring two exciting APIs that make signals even more powerful.

linkedSignal() - Signals That Depend on Each Other

linkedSignal() creates a writable signal that automatically resets when a source changes. Perfect for "dependent defaults".

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

const products = signal(['Laptop', 'Tablet', 'Phone']);

// Automatically selects the first product when the list changes
const selectedProduct = linkedSignal(() => products()[0]);

console.log(selectedProduct()); // "Laptop"

// Manually changeable
selectedProduct.set('Tablet');
console.log(selectedProduct()); // "Tablet"

// When the source changes, the value resets
products.set(['Monitor', 'Keyboard', 'Mouse']);
console.log(selectedProduct()); // "Monitor"

The difference from computed(): a linkedSignal is writable. You can manually override the value, but it resets when the source changes. This is incredibly useful for dropdown selections, pagination, or filters tied to a data source.

resource() - Loading Async Data

resource() connects signals with async operations. Instead of using HttpClient with RxJS pipes, you can load data in a signal-based way.

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

const userId = signal(1);

const userResource = resource({
  request: () => ({ id: userId() }),
  loader: async ({ request }) => {
    const response = await fetch(
      `https://api.example.com/users/${request.id}`
    );
    return response.json();
  }
});

// In the template:
// @if (userResource.isLoading()) {
//   <p>Loading...</p>
// }
// @if (userResource.value()) {
//   <p>{{ userResource.value().name }}</p>
// }
// @if (userResource.error()) {
//   <p>Error: {{ userResource.error() }}</p>
// }

// Load a new user - automatically triggers the loader
userId.set(2);

The genius part: resource() gives you loading state, error handling, and automatic reloading for free. When userId changes, a new request is automatically triggered. No switchMap needed.

Angular input() for Route Parameters: Ditch ActivatedRoute for Good
Angular 16+ lets you bind route parameters directly via input() – no more ActivatedRoute boilerplate.

🔧 Migration: From BehaviorSubject to signal()

You have existing code with RxJS and want to migrate step by step? No problem. Angular provides interop functions that make the transition smooth.

Before: RxJS-based

import { BehaviorSubject, combineLatest, map } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class CartService {
  private items$ = new BehaviorSubject<CartItem[]>([]);
  private discount$ = new BehaviorSubject<number>(0);

  totalPrice$ = combineLatest([this.items$, this.discount$]).pipe(
    map(([items, discount]) => {
      const subtotal = items.reduce((sum, item) => sum + item.price, 0);
      return subtotal * (1 - discount / 100);
    })
  );

  addItem(item: CartItem) {
    this.items$.next([...this.items$.value, item]);
  }

  setDiscount(percent: number) {
    this.discount$.next(percent);
  }
}

After: Signal-based

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

@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);
  private discount = signal(0);

  totalPrice = computed(() => {
    const subtotal = this.items().reduce((sum, item) => sum + item.price, 0);
    return subtotal * (1 - this.discount() / 100);
  });

  addItem(item: CartItem) {
    this.items.update(current => [...current, item]);
  }

  setDiscount(percent: number) {
    this.discount.set(percent);
  }
}

Less code. Less boilerplate. No subscriptions you can forget to clean up. And the best part: you can mix both approaches.

Interop: toSignal() and toObservable()

For gradual migration, Angular provides bridges between both worlds:

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({...})
export class UserComponent {
  private http = inject(HttpClient);

  // Observable → Signal
  users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: [] }
  );

  // Signal → Observable (if you need it)
  searchTerm = signal('');
  searchTerm$ = toObservable(this.searchTerm);
}

toSignal() is especially handy: it automatically subscribes, gives you the latest value as a signal, and unsubscribes on destroy. The HttpClient stays RxJS-based, but in your template you work with signals.

Migration Tips

  • Step 1: Start with simple BehaviorSubject fields in components - they're the easiest to replace.
  • Step 2: Replace combineLatest + map with computed() - it's almost always a 1:1 swap.
  • Step 3: Use toSignal() for HttpClient calls instead of manually subscribing.
  • Step 4: Leave complex RxJS streams (WebSockets, debounce chains) alone for now - they're better off with RxJS.

⚠️ Common Pitfalls

Before you dive in, a few things you should know:

1. Signals are synchronous

Unlike Observables, signals always deliver a value immediately. That's usually an advantage, but when you're waiting for async data, you'll need resource() or toSignal() with an initialValue.

2. Use effect() sparingly

Effects are powerful, but they can quickly lead to hard-to-follow code when they trigger each other. Prefer computed() for derived values and only use effect() for actual side effects (DOM manipulation, logging, API calls).

3. Respect immutability

Signals detect changes through reference comparison. That means: mutating an array won't work - you need to create a new one:

// ❌ Wrong - Signal won't detect the change
const list = signal(['a', 'b']);
list().push('c'); // Nope!

// ✅ Right - Create a new array
list.update(current => [...current, 'c']);

4. Customize the equality function

By default, Angular uses Object.is() for comparison. For objects, you can provide a custom equality function:

const user = signal(
  { name: 'John', age: 30 },
  { equal: (a, b) => a.name === b.name && a.age === b.age }
);
Why You Should Only Use TypeScript
TypeScript gives you type safety, better tooling and fewer bugs.

💡 Conclusion

Angular Signals aren't just hype - they're a thoughtful addition that makes the framework significantly more approachable. You get reactive state management that feels like plain TypeScript, without the RxJS learning curve.

My recommendation:

  • New components → Signals first
  • Existing code → Migrate gradually with toSignal()
  • Complex streams → RxJS remains your friend
  • And above all: don't be afraid to make the switch. The API is small, intuitive, and genuinely fun to use.

Got questions or your own experiences with Angular Signals? Drop a comment or reach out via email. I'd love to hear from you! 🚀

Artikel teilen:Share article: