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.


⚙️ 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()); // 43You'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 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.
🔧 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
BehaviorSubjectfields in components - they're the easiest to replace. - Step 2: Replace
combineLatest+mapwithcomputed()- 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 }
);
💡 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! 🚀
Discover more articles
Angular Signals: Reaktives State-Management ohne RxJS 🚀
Angular Signals bringen reaktives State-Management ohne RxJS-Komplexität. Lerne signal(), computed() und effect() für sauberen Angular-Code.
Logging in Angular: A Powerful Tool for Debugging and Monitoring 🕵️
Logging in Angular is essential. From TypeScript decorators to structured LoggerServices – here's how to debug efficiently.
Angular and TailwindCSS: Utility-First CSS Meets Components 🎨
Angular and TailwindCSS are a powerful duo. Here's how to set up Tailwind in your Angular project and use utility-first CSS effectively.
Angular input() for Route Parameters: Ditch ActivatedRoute for Good 🚀
Angular 16+ lets you bind route parameters directly via input() – no more ActivatedRoute boilerplate. Here’s how!