When Angular first announced Signals, I was cautiously optimistic. After years of wrangling RxJS subscriptions, managing async pipes, and debugging memory leaks caused by forgotten unsubscribe calls, the promise of a simpler reactivity model was appealing. Now, several months into using Signals in a production Ionic application, I can share what I have actually learned from the experience.
Signals Basics: What They Actually Are
At its core, a Signal is a wrapper around a value that notifies consumers when that value changes. If you have used Vue's ref() or Solid's createSignal(), the concept is identical. The Angular implementation looks like this:
import { signal, computed, effect } from '@angular/core';
// Writable signal
const count = signal(0);
// Read the value
console.log(count()); // 0
// Update the value
count.set(5);
count.update(prev => prev + 1);
// Computed signal (derived state)
const doubled = computed(() => count() * 2);
// Effect (side effects when signals change)
effect(() => {
console.log(`Count is now: ${count()}`);
});
The key insight is that signals are synchronous and pull-based. Unlike Observables, which push values to subscribers, signals hold a current value that consumers pull when they need it. Angular's change detection can then ask each signal "did you change?" rather than re-running the entire component tree.
Computed Signals: Where the Power Lives
Computed signals are where Signals really start to shine. They automatically track their dependencies and only recompute when those dependencies change. Here is a real-world example from our chat application:
@Component({
selector: 'app-conversation-list',
template: `
<input [ngModel]="searchTerm()" (ngModelChange)="searchTerm.set($event)" />
<div *ngFor="let conv of filteredConversations()">
{{ conv.name }} - {{ conv.lastMessage }}
</div>
<p>Showing {{ filteredConversations().length }} of {{ conversations().length }}</p>
`
})
export class ConversationListComponent {
conversations = signal<Conversation[]>([]);
searchTerm = signal('');
filteredConversations = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.conversations();
return this.conversations().filter(c =>
c.name.toLowerCase().includes(term)
);
});
}
Before Signals, this would have required a BehaviorSubject for the search term, a combineLatest to merge it with the conversations observable, and a map operator to do the filtering. Plus an async pipe in the template and careful subscription management. The Signal version is half the code, easier to read, and has no subscription cleanup to worry about.
Effects: Use Sparingly
Effects are the escape hatch for side effects, like logging, syncing to localStorage, or calling an API when a value changes. They are the most "RxJS-like" part of Signals, and they are also the part you should use most carefully:
export class SettingsComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
// Sync theme to localStorage whenever it changes
effect(() => {
localStorage.setItem('theme', this.theme());
document.body.classList.toggle('dark-mode', this.theme() === 'dark');
});
}
}
A word of caution: effects run at least once and then re-run whenever any signal they read changes. If your effect reads five signals, it will run whenever any of them change. This can lead to unexpected execution frequency if you are not careful. I have a personal rule: if an effect is reading more than two signals, I step back and ask whether a computed signal with a narrower effect would be cleaner.
Migrating from RxJS: A Gradual Approach
You do not need to rewrite your entire application to use Signals. Angular provides toSignal() and toObservable() to bridge between the two worlds. This is the approach I have taken in our existing codebase:
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
@Component({ /* ... */ })
export class DashboardComponent {
// Convert an existing Observable to a Signal
private userService = inject(UserService);
currentUser = toSignal(this.userService.currentUser$, {
initialValue: null
});
// Use the signal in computed values
greeting = computed(() => {
const user = this.currentUser();
return user ? `Welcome back, ${user.name}` : 'Please log in';
});
}
My strategy has been to keep services using RxJS where it makes sense (HTTP calls, WebSocket streams, complex async operations) and convert to Signals at the component level using toSignal(). New components are written with Signals from the start. Over time, we will migrate services as well, but there is no rush. The interop layer works well enough that both can coexist indefinitely.
What I Would Do Differently
Looking back, I wish I had started with a stricter convention for signal naming. We use a mix of plain names and names suffixed with Sig, which gets confusing. Pick a convention and stick with it. I also wish I had been more aggressive about using computed from the start. My early Signal code had too many standalone signals that should have been derived state. The more you push into computed, the fewer bugs you get from inconsistent state.
Overall, Signals have made our Angular code simpler, more readable, and easier to reason about. They are not a replacement for RxJS in every case, but they have replaced it in about 70 percent of our component-level state management. If you are on Angular 17 or later and have not started experimenting with Signals yet, I would strongly encourage you to try them on your next feature.