Angular Signals vs RxJS vs NgRx: Which Should You Use?
Modern Angular applications need a reliable way to manage state, react to user interactions, handle asynchronous operations, and keep the user interface synchronized with changing data.
Angular Signals, RxJS, and NgRx are frequently discussed together, but they solve different problems.
Signals provide a reactive way to represent and derive application state. RxJS provides powerful tools for working with asynchronous streams and events. NgRx offers structured state management patterns for applications that benefit from centralized state, predictable updates, and explicit data flow.
The challenge is not deciding which technology is universally better. The challenge is understanding when each one is appropriate.
In this guide, we'll explore all three technologies using practical TypeScript examples, architecture diagrams, and real-world Angular scenarios.
By the end, you'll understand:
- What Angular Signals are and when to use them.
- How RxJS Observables and operators handle asynchronous data.
- How NgRx manages application state.
- The differences between Signals, RxJS, and NgRx.
- How these technologies can work together.
- How to choose an approach based on application complexity.
Whether you're learning Angular or building enterprise applications, this guide will help you make more informed architectural decisions.
Let's start with the most important distinction: these technologies are not direct replacements for one another.
1. What Are Angular Signals?
Angular Signals provide a way to represent reactive state in an Angular application.
A Signal holds a value and allows Angular to track where that value is read. When the value changes, Angular can notify the consumers that depend on it.
Signals are especially useful for:
- Component state.
- Selected items.
- Search and filter values.
- Derived values such as totals and counts.
- Shared state exposed through Angular services.
Why were Signals introduced?
Before Signals, Angular developers commonly used component properties, RxJS Observables, and change-detection mechanisms to manage changing data.
These approaches remain useful. However, many UI state problems are simpler when state can be read directly and dependencies are tracked automatically.
Signals provide a straightforward model for this kind of state.
Important Signal APIs
| API | Purpose |
|---|---|
signal() | Creates writable reactive state |
computed() | Derives a value from other reactive values |
effect() | Runs side effects in response to reactive dependencies |
linkedSignal() | Creates writable state linked to changing source state |
The first two APIs are the best starting point for beginners.
When should you use Signals?
Consider Signals when you need to manage:
- A selected transaction.
- A shopping cart count.
- A dashboard filter.
- A selected tab.
- A calculated total.
- UI state shared through a service.
Signals are not a replacement for every Observable or asynchronous workflow. For HTTP streams, event streams, cancellation, and complex asynchronous composition, RxJS remains valuable.
Let's see how Signals work in practice.
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Angular Signals Demo</h2>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">Increment</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
increment(): void {
this.count.update(value => value + 1);
}
reset(): void {
this.count.set(0);
}
}2. What Is RxJS?
RxJS is a library for working with asynchronous data streams and event sequences.
It provides Observables and operators that help you transform, combine, filter, and control the flow of data.
In Angular, RxJS is particularly useful for:
- HTTP requests.
- Search autocomplete.
- User input streams.
- WebSocket events.
- Combining multiple asynchronous operations.
- Cancelling outdated requests.
- Handling asynchronous errors.
What is an Observable?
An Observable represents a sequence of values that can arrive over time.
For example, a search field might emit:
"a" → "an" → "ang" → "angular"
Each new value can trigger additional processing.
RxJS operators allow you to control that processing.
Why are operators important?
Consider a search autocomplete feature.
Without appropriate operators, every keystroke could trigger an HTTP request.
With debounceTime(), the application can wait until the user pauses typing.
With distinctUntilChanged(), it can avoid processing consecutive identical values.
With switchMap(), it can switch to the latest search operation and unsubscribe from the previous inner Observable.
Together, these operators help create responsive search experiences.
Common RxJS operators
| Operator | Purpose |
|---|---|
map() | Transforms emitted values |
filter() | Keeps values that satisfy a condition |
tap() | Performs side effects without transforming emitted values |
debounceTime() | Waits for a pause between emissions |
switchMap() | Switches to a new inner Observable |
mergeMap() | Processes inner Observables concurrently |
concatMap() | Processes inner Observables sequentially |
forkJoin() | Combines final values from completing Observables |
RxJS is not simply a state management library. It is a general-purpose tool for reactive and asynchronous programming.
Next, let's build a working example.
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
startWith,
switchMap
} from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
interface User {
id: number;
name: string;
email: string;
}
interface SearchState {
users: User[];
loading: boolean;
error: string | null;
}
@Component({
selector: 'app-user-search',
standalone: true,
imports: [FormsModule],
template: `
<h2>Search Users</h2>
<input
type="text"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
placeholder="Search users..."
/>
@if (searchState().loading) {
<p>Loading...</p>
}
@if (searchState().error; as error) {
<p role="alert">{{ error }}</p>
}
@if (!searchState().loading && !searchState().error) {
<ul>
@for (user of searchState().users; track user.id) {
<li>{{ user.name }} — {{ user.email }}</li>
} @empty {
<li>No matching users found.</li>
}
</ul>
}
`
})
export class UserSearchComponent {
private readonly http = inject(HttpClient);
readonly query = signal('');
private readonly searchState$ = toSignal(
of(''),
{ initialValue: '' }
);
readonly searchState = toSignal(
of<SearchState>({
users: [],
loading: false,
error: null
}),
{
initialValue: {
users: [],
loading: false,
error: null
}
}
);
}import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
startWith,
switchMap
} from 'rxjs';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
interface User {
id: number;
name: string;
email: string;
}
interface SearchState {
users: User[];
loading: boolean;
error: string | null;
}
@Component({
selector: 'app-user-search',
standalone: true,
imports: [FormsModule],
template: `
<h2>Search Users</h2>
<input
type="text"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
placeholder="Search users..."
/>
@if (searchState().loading) {
<p>Loading...</p>
}
@if (searchState().error; as error) {
<p role="alert">{{ error }}</p>
}
@if (!searchState().loading && !searchState().error) {
<ul>
@for (user of searchState().users; track user.id) {
<li>{{ user.name }} — {{ user.email }}</li>
} @empty {
<li>No matching users found.</li>
}
</ul>
}
`
})
export class UserSearchComponent {
private readonly http = inject(HttpClient);
readonly query = signal('');
readonly searchState = toSignal(
toObservable(this.query).pipe(
map(query => query.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap(query => {
if (!query) {
return of<SearchState>({
users: [],
loading: false,
error: null
});
}
return this.http
.get<User[]>('https://jsonplaceholder.typicode.com/users')
.pipe(
map(users =>
users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase())
)
),
map(users => ({
users,
loading: false,
error: null
})),
startWith<SearchState>({
users: [],
loading: true,
error: null
}),
catchError(() =>
of<SearchState>({
users: [],
loading: false,
error: 'Unable to load users. Please try again.'
})
)
);
})
),
{
initialValue: {
users: [],
loading: false,
error: null
}
}
);
}Important RxJS Best Practice
PRO TIP: Choose higher-order operators based on the behavior your application needs. Use switchMap() when the latest request should replace an earlier request, such as search autocomplete. Use mergeMap() when independent operations can run concurrently. Use concatMap() when operations must be processed sequentially. Use exhaustMap() when new triggers should be ignored while an operation is active, such as preventing repeated form submissions. Don't choose an operator simply because it is popular. Consider ordering, concurrency, cancellation, and whether the operation can safely be repeated.
3. What Is NgRx?
NgRx is a collection of libraries for Angular applications that need structured reactive patterns for state management.
NgRx Store provides a centralized state container and predictable state updates. NgRx Effects helps manage side effects such as API calls. Selectors provide reusable ways to read and derive state.
NgRx is especially useful when an application has complex shared state, multiple features depending on the same data, and a need for consistent update patterns.
The main NgRx concepts
Store
Holds the application state.
Actions
Describe events or operations, such as requesting transactions or successfully loading data.
Reducers
Calculate the next state from the current state and a dispatched action.
Selectors
Read or derive values from the Store.
Effects
Handle side effects, such as API requests, and dispatch actions representing the outcome.
Why use NgRx?
Consider a financial dashboard containing:
- Transactions.
- Account balances.
- Search filters.
- Selected accounts.
- Pagination.
- Loading states.
- API errors.
When many components depend on the same state, managing every interaction independently can become difficult.
NgRx provides an explicit flow that can make complex state transitions easier to understand, test, and debug.
However, NgRx introduces additional concepts and code. A small application with only a few local state variables may not need it.
Let's look at the architecture.
A Minimal NgRx Store Example
Language: TypeScript
For this article, demonstrate the essential Store flow with a small counter.
This is a minimal example, not the full API-driven implementation of a financial dashboard.
// counter.actions.ts
import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');
export const reset = createAction('[Counter] Reset');// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import {
decrement,
increment,
reset
} from './counter.actions';
export const initialState = 0;
export const counterReducer = createReducer(
initialState,
on(increment, state => state + 1),
on(decrement, state => state - 1),
on(reset, () => initialState)
);// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { counterReducer } from './counter.reducer';
export const appConfig: ApplicationConfig = {
providers: [
provideStore({
count: counterReducer
})
]
};// counter.component.ts
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import {
decrement,
increment,
reset
} from './counter.actions';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>NgRx Counter</h2>
<p>Count: {{ count$ | async }}</p>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
private readonly store = inject(Store<{ count: number }>);
readonly count$ = this.store.select('count');
increment(): void {
this.store.dispatch(increment());
}
decrement(): void {
this.store.dispatch(decrement());
}
reset(): void {
this.store.dispatch(reset());
}
}Ensure that the AsyncPipe is available to this standalone component. One option is to import it explicitly:
import { AsyncPipe } from '@angular/common';import { AsyncPipe } from '@angular/common';
imports: [AsyncPipe]Explanation
This example demonstrates the core NgRx flow:
A user clicks a button. The component dispatches an action. The reducer calculates the next state. The Store exposes the updated state. The component receives the new value through its Observable subscription.
The AsyncPipe subscribes to the selected state and manages the subscription lifecycle.
Version note: Check your installed NgRx version and its official documentation before publishing. The example uses the classic Store APIs, which are useful for explaining the fundamentals.
4. Angular Signals vs RxJS vs NgRx: Key Differences
Now that we've seen each technology in action, let's compare their responsibilities.
| Feature | Angular Signals | RxJS | NgRx Store |
|---|---|---|---|
| Primary purpose | Reactive state | Asynchronous streams and reactive composition | Structured application state management |
| Main abstraction | Signal | Observable | Store |
| Typical data access | Read a Signal by calling it | Subscribe or use an Observable consumer | Select state from the Store |
| State updates | set() and update() | Depends on the source and operators | Dispatch actions processed by reducers |
| Derived data | computed() | Operators such as map() and combineLatest() | Selectors, often combined with reactive streams |
| Asynchronous operations | Can expose state derived from async sources | A core strength | Commonly handled with Effects and RxJS |
| Shared state | Services can expose Signals | Services can expose Observables | Centralized Store |
| Complexity | Low for straightforward state | Varies with stream complexity | More structure and concepts |
| Best starting point | Local reactive UI state | Events and asynchronous data | Complex shared application state |
Signals are not a replacement for RxJS
Signals and RxJS have different strengths.
Signals are excellent for representing current state and deriving values from that state.
RxJS is particularly useful when data arrives over time and needs to be combined, filtered, delayed, cancelled, or otherwise processed through asynchronous pipelines.
Angular supports interoperability between the two through APIs such as toSignal() and toObservable().
NgRx is not a replacement for Signals or RxJS
NgRx Store provides a structured state management approach. It also integrates with RxJS and can coexist with Signals in an Angular application.
An application can use NgRx for shared business state, RxJS for asynchronous workflows, and Signals for convenient local or derived UI state.
The important question is which responsibility each technology should handle.
Pro Tip
Start with the simplest solution that satisfies your requirements. For a counter, selected tab, or filter value, a Signal may be all you need. For asynchronous search, request cancellation, or combining event streams, RxJS may be the better fit. For complex shared state with multiple consumers and explicit state transitions, consider NgRx. Don't introduce a centralized state management library simply because the application uses Angular. Evaluate complexity, maintainability, testing requirements, and team needs first
Start with the simplest solution that satisfies your requirements.
For a counter, selected tab, or filter value, a Signal may be all you need.
For asynchronous search, request cancellation, or combining event streams, RxJS may be the better fit.
For complex shared state with multiple consumers and explicit state transitions, consider NgRx.
Don't introduce a centralized state management library simply because the application uses Angular. Evaluate complexity, maintainability, testing requirements, and team needs first
Discussion & Feedback
0 thoughts shared by the community
Loading discussion thread...