← Back to blog

Angular Signals and RxJS: when an effect() ends up doing too much

Why splitting a search screen with filters and pagination across several effect() calls makes the code harder to follow, and how to describe that flow with RxJS.

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 (a Promise).
  • rxResource(): the request returns an Observable.
  • httpResource(): the request is described directly as an HTTP call, on top of HttpClient.

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:

  • params says what the request depends on. It is what the three effect() calls at the start were trying to express, but in one place.
  • stream says how the request is made. If params changes while one is in progress, Angular keeps the new one, just like switchMap.
  • 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 params is 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.

Found it useful?

If you want to discuss it or have a similar project, get in touch.

Let's talk