Angular 16 is out now: Learn how to Replace RxJS with Signals
You get a single, concrete, real-life case here—just a direct code comparison. No extras, no fluff.
But but but… Signals & RxJS right — it’s not supposed to replace it?
The title was a deliberate bait to stir up some debate—and I admit I couldn’t resist. My example fully swaps out RxJS for Signals.
Signals should simplify reactive code and claim RxJS’s role, but only regarding synchronous operations — asynchronous flows should stay untouched :).
Search & Pagination (RxJS)
A compact feature enables user lookup with pagination, implemented using RxJS. Its purpose is to demonstrate how synchronous RxJS code can be simplified with Signals.
When I sought feedback on this code from my peers, each one of them found different things to improve in my initial code and had different vision on how this could look like.
On top of that, sporadic defects appeared and potential memory leaks lurked. This is exactly why working with synchronous RxJS is not ideal. Chances are, seeing this snippet, you've already pictured a distinct approach for the solution.
const users = [
{ id: 1, name: 'Spiderman' },
{ id: 2, name: 'Hulk' },
{ id: 3, name: 'Wolverine' },
{ id: 4, name: 'Cyclops' },
{ id: 5, name: 'Venom' },
];
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<input [ngModel]="searchInput$ | async" (ngModelChange)="searchUser($event)" placeholder="Search">
<ul>
<li *ngFor="let user of paginatedAndFilteredUsers$ | async">{{ user.name }}</li>
</ul>
<button (click)="goToPrevPage()">Previous</button>
pag. {{ currentPage$ | async }}
<button (click)="goToNextPage()">Next</button>
`,
})
export class App {
readonly firstPage = 1;
itemsPerPage = 2;
searchInput$ = new BehaviorSubject('');
currentPage$ = new BehaviorSubject(this.firstPage);
paginatedAndFilteredUsers$ = combineLatest([
this.currentPage$.pipe(distinctUntilChanged()), // trigger only when it actually changes
this.searchInput$.pipe(
distinctUntilChanged(),
map((searchText) =>
users.filter((user) =>
user.name.toLowerCase().includes(searchText.toLowerCase())
)
)
),
]).pipe(
map(([currentPage, filteredUsers]) => {
const startIndex = (currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
return filteredUsers.slice(startIndex, endIndex);
})
);
searchUser(searchText: string): void {
this.searchInput$.next(searchText);
if (this.currentPage$.value > this.firstPage) {
this.currentPage$.next(this.firstPage);
}
}
goToPrevPage(): void {
this.currentPage$.next(Math.max(this.currentPage$.value - 1, 1));
}
goToNextPage(): void {
this.currentPage$.next(
Math.min(this.currentPage$.value + 1, this.itemsPerPage + 1)
);
}
}
Search & Pagination (Signals)
The identical pipeline, yet built on Signals instead.
const users = [
{ id: 1, name: 'Spiderman' },
{ id: 2, name: 'Hulk' },
{ id: 3, name: 'Wolverine' },
{ id: 4, name: 'Cyclops' },
{ id: 5, name: 'Venom' },
];
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<input [ngModel]="searchInput()" (ngModelChange)="searchUser($event)" placeholder="Search">
<ul>
<li *ngFor="let user of paginatedAndFilteredUsers()">{{ user.name }}</li>
</ul>
<button (click)="goToPrevPage()">Previous</button>
pag. {{ currentPage() }}
<button (click)="goToNextPage()">Next</button>
`,
})
export class App {
readonly firstPage = 1;
itemsPerPage = 2;
searchInput = signal('');
currentPage = signal(this.firstPage);
paginatedAndFilteredUsers = computed(() => {
const startIndex = (this.currentPage() - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
return users
.filter((user) =>
user.name.toLowerCase().includes(this.searchInput().toLowerCase())
)
.slice(startIndex, endIndex);
});
searchUser(searchText: string): void {
this.searchInput.set(searchText);
if (this.currentPage() > this.firstPage) {
this.currentPage.set(this.firstPage);
}
}
goToPrevPage(): void {
this.currentPage.update((currentPage) => Math.max(currentPage - 1, 1));
}
goToNextPage(): void {
this.currentPage.update((currentPage) =>
Math.min(currentPage + 1, this.itemsPerPage + 1)
);
}
}
Now comparison one by one
//RxJs
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<input [ngModel]="searchInput$ | async" (ngModelChange)="searchUser($event)" placeholder="Search">
<ul>
<li *ngFor="let user of paginatedAndFilteredUsers$ | async">{{ user.name }}</li>
</ul>
<button (click)="goToPrevPage()">Previous</button>
pag. {{ currentPage$ | async }}
<button (click)="goToNextPage()">Next</button>
`,
})
// Signals
@Component({
selector: 'my-app',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<input [ngModel]="searchInput()" (ngModelChange)="searchUser($event)" placeholder="Search">
<ul>
<li *ngFor="let user of paginatedAndFilteredUsers()">{{ user.name }}</li>
</ul>
<button (click)="goToPrevPage()">Previous</button>
pag. {{ currentPage() }}
<button (click)="goToNextPage()">Next</button>
`,
})
//RxJS
readonly firstPage = 1;
itemsPerPage = 2;
searchInput$ = new BehaviorSubject('');
currentPage$ = new BehaviorSubject(this.firstPage);
paginatedAndFilteredUsers$ = combineLatest([
this.currentPage$.pipe(distinctUntilChanged()),
this.searchInput$.pipe(
distinctUntilChanged(),
map((searchText) =>
users.filter((user) =>
user.name.toLowerCase().includes(searchText.toLowerCase())
)
)
),
]).pipe(
map(([currentPage, filteredUsers]) => {
const startIndex = (currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
return filteredUsers.slice(startIndex, endIndex);
})
);
//Signals
readonly firstPage = 1;
itemsPerPage = 2;
searchInput = signal('');
currentPage = signal(this.firstPage);
paginatedAndFilteredUsers = computed(() => {
const startIndex = (this.currentPage() - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
return users
.filter((user) =>
user.name.toLowerCase().includes(this.searchInput().toLowerCase())
)
.slice(startIndex, endIndex);
});
//RxJS
searchUser(searchText: string): void {
this.searchInput$.next(searchText);
if (this.currentPage$.value > this.firstPage) {
this.currentPage$.next(this.firstPage);
}
}
//Signals
searchUser(searchText: string): void {
this.searchInput.set(searchText);
if (this.currentPage() > this.firstPage) {
this.currentPage.set(this.firstPage);
}
}
//RxJS
goToPrevPage(): void {
this.currentPage$.next(Math.max(this.currentPage$.value - 1, 1));
}
goToNextPage(): void {
this.currentPage$.next(
Math.min(this.currentPage$.value + 1, this.itemsPerPage + 1)
);
}
//Signals
goToPrevPage(): void {
this.currentPage.update((currentPage) => Math.max(currentPage - 1, 1));
}
goToNextPage(): void {
this.currentPage.update((currentPage) =>
Math.min(currentPage + 1, this.itemsPerPage + 1)
);
}
Conclusion
Signals remain in developer preview, so keep that in mind. They’re clearly part of Angular’s future, and honestly, I’m impressed with how they streamline and simplify code that originally depended on synchronous RxJS patterns.
Disclaimer
Reading this in 2024? Then please flag me down and call me out — I haven’t touched this content since it was written. The example draws on the initial Signals developer preview shipped with Angular 16 at its launch.
Things will shift once the full Signals design is rolled out. If you’ve been following the RFCs and proposals, you’d likely notice that this example would change with input-based signals, which aren’t available yet.
I hope you liked my article!
Enjoyed it? Then you might appreciate what I’m up to on Twitter. I run live Twitter Spaces on Angular, featuring GDEs & industry pros — join in to ask questions live, or catch replays as short clips afterward.
Interested? Give me a follow at Twitter @DanielGlejzner — it means a lot. Thanks!




