In Angular projects I have come across the same setup several times: a
screen has to react to changes in a search box, some filters or the
pagination, and that logic is spread across several effect() calls.
It is not an approach I agree with. I understand how people end up there: at first it feels convenient. Each effect watches a signal and runs an operation when it changes. The problem shows up when you need to understand the whole flow: what triggers a request, which values it uses, what happens if several criteria change in a row, and where the state gets updated.
When I review or refactor code, one of the things I value most is being able to understand what it does without mentally rebuilding a chain of side effects.
Let's look at it with a simple example.
Signals and effect(), in a nutshell
Signals arrived in Angular 16, first as a developer preview. They are a way to represent reactive state and derive values from it.
A signal holds a value we can read and update:
readonly page = signal(1);
this.page.set(2);
console.log(this.page()); // 2
A computed() derives a value from other signals:
readonly price = signal(100);
readonly taxRate = signal(0.21);
readonly totalPrice = computed(
() => this.price() * (1 + this.taxRate()),
);
An effect() runs a function once when it is created and again every
time the signals it reads change:
effect(() => {
console.log('Current page:', this.page());
});
It is worth keeping the three pieces apart. A signal represents state;
computed() derives state; effect() runs side effects when that state
changes.
An effect makes sense, for example, to keep a signal in sync with an imperative external API. What I want to discuss here is something else: using effects as a general mechanism to coordinate state changes, requests and other reactions with each other.
RxJS: describing what happens over time
RxJS works with Observables: sources that emit values over time. It lets you transform those values, combine them, filter them and coordinate asynchronous operations through operators.
For this example a few are enough:
combineLatest: combines the latest values from several sources.debounceTime(300): waits 300 ms without new emissions before letting the value through.distinctUntilChanged(): drops consecutive identical values.switchMap: switches to the new observable and unsubscribes from the previous one.
And in the template, the async pipe, which subscribes to the
observable and unsubscribes when the component is destroyed.
The practical difference is that we can describe the flow in one place: which values come in, how they are combined and which operation runs afterwards.
The problem: a table with search and pagination
Imagine a user admin screen with:
- A search field.
- A status filter.
- A pagination control.
- A table showing the results from the server.
When any of those criteria changes, we want to fetch the matching data using the current combination of search, page and filter.
The antipattern: spreading the work across several effect() calls
A first approach could look like this:
readonly search = signal('');
readonly page = signal(1);
readonly status = signal('all');
constructor() {
effect(() => {
this.search();
this.loadUsers();
});
effect(() => {
this.page();
this.loadUsers();
});
effect(() => {
this.status();
this.loadUsers();
});
}
The code is deliberately simple, but it already has several problems.
The first one is readability. The logic that coordinates search,
pagination and filter is split across three entry points. To know what
happens when a criterion changes, we have to remember what each effect
does and how it relates to loadUsers().
The second one is less obvious. loadUsers() needs to read the three
signals to build the request, and an effect registers as a dependency
any signal read while it runs, including inside the methods it calls.
So, in practice, each effect depends on all three signals. When the
screen loads, three requests go out, and every time a criterion changes,
another three.
You can fix it with untracked(), but that is already a hint: we are
adding pieces to keep the effects under control instead of describing
what we want to happen.
And then there are concurrent requests. If loadUsers() starts a new
subscription on every call without cancelling the previous ones, an old
response can arrive late and overwrite more recent results.
We could put everything into a single effect, add conditions or helper signals. We would still have no debounce and no cancellation, and the flow would not be any easier to follow.
The problem is not that effect() exists, nor that every effect is
wrong. It is using it as a substitute for a reactive flow that has
several inputs and asynchronous operations to coordinate.
An alternative: describing the flow with RxJS
We can model the search, the page and the status as sources of values, combine them and make a request every time the combination changes.
In the example I use a FormControl for the search and
BehaviorSubject for the page and the filter:
import { FormControl } from '@angular/forms';
import {
BehaviorSubject,
combineLatest,
debounceTime,
distinctUntilChanged,
startWith,
switchMap,
} from 'rxjs';
readonly searchControl = new FormControl('', {
nonNullable: true,
});
readonly page$ = new BehaviorSubject(1);
readonly status$ = new BehaviorSubject('all');
readonly search$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
startWith(this.searchControl.value),
distinctUntilChanged(),
);
readonly users$ = combineLatest([
this.search$,
this.page$.pipe(distinctUntilChanged()),
this.status$.pipe(distinctUntilChanged()),
]).pipe(
switchMap(([search, page, status]) =>
this.usersService.getUsers({ search, page, status }),
),
);
In the template we consume the result with async:
@if (users$ | async; as users) {
<app-users-table [users]="users" />
}
The example assumes getUsers() returns an Observable with the users.
In a real application you would still need a loading state, error
handling and proper typing for the filter.
What is going on here?
1. We combine the latest values
combineLatest keeps the latest value from each source. Once all of
them have emitted at least once, every change produces a new
combination.
BehaviorSubject emits its value as soon as we subscribe, but
valueChanges does not emit until the user types. That is why we add
startWith: without it, the table would not load when entering the
screen.
2. We do not fetch data on every keystroke
debounceTime(300) waits until the user has stopped typing for 300 ms
before letting the value through. We put it before startWith so the
initial load does not have to wait those 300 ms.
3. We avoid repeated requests
distinctUntilChanged() drops consecutive identical emissions. If the
user types a letter and deletes it before the debounce time passes, no
request identical to the previous one goes out.
4. We keep only the latest request
switchMap switches to the observable for the latest combination and
unsubscribes from the previous one.
If getUsers() uses Angular's HttpClient, that unsubscription also
cancels the HTTP request in progress. With other sources it depends:
unsubscribing does not always stop the operation behind it. What
switchMap does guarantee is that the old response no longer reaches
the table.
A behaviour detail: what happens to the page when searching?
There is a product decision worth making explicitly: when the user changes the search or the filter, do we keep the current page or go back to the first one?
If the user is on page 8 and applies a filter that only has one page of results, keeping page 8 leaves the table empty. Many interfaces solve it by going back to the first page.
That decision should not be hidden inside an effect. It is better placed in the action that changes the criterion:
onStatusChanged(status: string): void {
this.status$.next(status);
this.page$.next(1);
}
Here combineLatest emits twice in a row (first with the new filter and
the old page, then with page 1), but switchMap discards the first
request and we keep the right one.
The search needs a bit more care: if we reset the page on every
keystroke, the page changes before the debounce time passes and we
send a request with the old text. The sensible option is to reset the
page when the value already filtered by debounceTime arrives, not on
every key.
What matters is that the rule is defined on purpose and does not show up as a side effect of how the effects are chained.
What Angular offers today: resource() and rxResource()
Angular has a piece designed exactly for this case: the resource APIs. They appeared as experimental in version 19 and became stable in 22.
The idea is simple: you tell it which signals the request depends on and how to make it, and Angular takes care of the rest. When those signals change, it runs the request again, discards the previous one and gives you the result, the loading state and the error as signals.
There are three variants:
resource(): the request is an async function (aPromise).rxResource(): the request returns anObservable.httpResource(): the request is described directly as an HTTP call, on top ofHttpClient.
Since our getUsers() already returns an Observable, we use
rxResource(). The same example would look like this:
import { signal } from '@angular/core';
import { rxResource, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { debounceTime, distinctUntilChanged } from 'rxjs';
readonly searchControl = new FormControl('', {
nonNullable: true,
});
readonly search = toSignal(
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
),
{ initialValue: this.searchControl.value },
);
readonly page = signal(1);
readonly status = signal('all');
readonly users = rxResource({
params: () => ({
search: this.search(),
page: this.page(),
status: this.status(),
}),
stream: ({ params }) => this.usersService.getUsers(params),
});
onStatusChanged(status: string): void {
this.status.set(status);
this.page.set(1);
}
And in the template:
@if (users.isLoading()) {
<p>Loading…</p>
}
@if (users.error()) {
<p>The users could not be loaded.</p>
}
@if (users.hasValue()) {
<app-users-table [users]="users.value()" />
}
What we used to solve with combineLatest and switchMap now lives in
params and stream:
paramssays what the request depends on. It is what the threeeffect()calls at the start were trying to express, but in one place.streamsays how the request is made. Ifparamschanges while one is in progress, Angular keeps the new one, just likeswitchMap.distinctUntilChanged()is no longer needed for the page or the filter: if you set a signal to the value it already had, it does not notify any change.- The filter change and the page reset happen one after the other, and
paramsis recomputed only once, so only one request goes out. - The loading state and the error come built in, something we would have to build by hand in the RxJS version.
The search still uses RxJS for the debounce: the FormControl emits
an observable, we wait 300 ms and turn it into a signal with
toSignal(). Angular 22 also ships a debounced() function to do this
directly with signals, but for now it is experimental.
If getUsers() did nothing more than call HttpClient,
httpResource() would let you skip the service and describe the request
directly in the component.
So what about Signals?
Signals and RxJS do not have to compete. They solve related but different needs.
Signals work very well to represent the current state and derive values. RxJS shines when you need to describe events and asynchronous operations over time: combining inputs, waiting, transforming emissions or switching requests.
And they can be combined. If the page and the filter are already signals
in your component, toObservable() turns them into observables to put
them in the same flow. The other way round, toSignal() lets you
consume the result as a signal from the template or from Signals-based
code.
Does rxResource() mean RxJS is no longer needed? For this case,
loading data that depends on the state of the screen, today I would
start with a resource. It is what Angular proposes and the code is
shorter.
RxJS still makes sense when what you need to model is a stream of events
rather than a request that depends on some state: a websocket, retries
with backoff, polling, combining user events or any logic where time
matters. And rxResource() does not rule that out: you can still use
operators inside stream.
My rule of thumb is practical: if the problem is coordinating several
sources of change and asynchronous operations, I prefer that flow to be
written explicitly, whether with a resource or with RxJS. When
understanding why a request is sent means jumping between several
effect() calls, it is usually a good sign that the design needs a
second look.
Maintainability matters too
In a refactor I am not only looking to reduce lines of code. I want the intent to be visible.
When I read this:
combineLatest([search$, page$, status$]).pipe(
switchMap(([search, page, status]) =>
this.usersService.getUsers({ search, page, status }),
),
);
I quickly see the inputs and the operation that runs. And if I want to
know how the search behaves, I go to search$ and the debounce is
right there.
That does not mean RxJS is always the simplest option. A flow with too many operators or nesting also becomes hard to maintain. Clarity does not come from choosing a particular API, but from modelling the problem so its rules are easy to follow.
But when we are coordinating search, filters, pagination and server requests, I prefer to express those relationships as a flow rather than spread them across effects that trigger each other.
Wrapping up
I have come across this pattern more than once: using effect() to
coordinate changes that would be easier to understand as an explicit
reactive flow.
I do not think the answer is to replace every effect with RxJS. I think it is worth asking what we are trying to model and choosing the tool that expresses it most clearly.
If reading a component forces you to mentally rebuild which effect triggers which and what state ends up changing, maybe the problem is not the number of lines, but how the reactivity is represented.
Sometimes a good refactor starts with something as simple as separating responsibilities and making the data flow visible.