The short answer: Use Angular Signals for synchronous state that a
component owns and shows in its template, such as counters, toggles, and
derived totals. Use RxJS for asynchronous streams, such as debounced search
boxes, WebSockets, and multi-step request flows, where operators like
switchMap and
debounceTime do the heavy lifting. For simple
"fetch data and show it" cases, Angular's resource APIs give you loading and
error state as signals. If you've ever chased a memory leak from a forgotten
subscription, or wondered how Angular decides what to re-render, this
comparison of Angular Signals vs RxJS shows where each tool fits.
Many tutorials treat Signals and RxJS as an either/or choice, as if Signals exist to replace Observables. That framing works for a simple counter app, but real applications need both: Signals for local UI state, and RxJS for talking to servers and handling events over time. This article explains the difference with small examples, then builds one complete mini-app, a tutorial catalog for a site called Codingvila, that uses both.
We'll cover the core difference between the two, when to use Signals for
component state and computed values, how Signals fit with zoneless Angular,
when to keep RxJS for async streams, how to bridge them with
toSignal() and
toObservable(), where Angular's resource APIs
fit, and which mistakes to avoid. The examples were reviewed against the
Angular 22 documentation. Where an API is newer or still experimental, the
text says so.
Signals vs RxJS at a Glance
If you only need a quick comparison, this table summarizes the differences. The sections below explain each row with code.
| Feature | Angular Signals | RxJS Observables |
|---|---|---|
| Primary purpose | State: the current value | Streams: values over time |
| Current value | Always has one, readable synchronously |
May not have emitted anything yet (a
BehaviorSubject is the exception)
|
| Telling Angular to update the view | Reading a signal in a template notifies Angular, and this works in zoneless apps |
RxJS knows nothing about the view, so use the
async pipe or
toSignal()
|
| Operators |
Few built in: computed() and
linkedSignal(), plus an experimental
debounced()
|
A large set, such as debounceTime,
retry,
delay, and
combineLatest
|
| Cancellation | Resources abort an outstanding request when their inputs change |
Unsubscribe, or use switchMap to drop
the previous request
|
| Cleanup | Nothing to clean up when reading a signal |
Unsubscribe, or use the async pipe,
toSignal(), or
takeUntilDestroyed
|
| Best for | Toggles, totals, form state, component inputs | Typing, WebSockets, multi-step request flows |
Prerequisites
To follow along, you should be comfortable with:
-
Angular with standalone components. Version 20 or later is a good minimum,
because
effect(),toSignal(), andtoObservable()became stable in v20, and signalinput()was stable before that. The resource APIs in Step 5 are stable in version 22, so use v22 for that section. Components are standalone by default from version 19, sostandalone: trueis optional there; it's kept in the examples so they also read clearly on older versions. Check the Angular update guide for the Node.js and TypeScript versions your target release requires. - Basic TypeScript syntax, interfaces, and arrow functions.
-
The basics of RxJS: observables, operators (
map,filter,switchMap), and subscriptions.
Every example that uses HttpClient needs the
client registered, or Angular throws a "No provider for HttpClient" error.
This is the setup used by the whole article:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()]
};
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));
The sample app uses a small data model and three placeholder API routes. Codingvila is used here only as a sample brand: these routes are not a real API, so point the examples at your own backend, a mock server, or a development proxy.
// tutorial.model.ts
export interface Tutorial {
id: number;
title: string;
// Prices are stored as whole cents to avoid floating-point rounding bugs
priceCents: number;
}
// Routes the examples assume (all return JSON):
// GET /api/tutorials/featured -> Tutorial[]
// GET /api/tutorials?q=angular -> Tutorial[] (search by title)
// GET /api/tutorials/1 -> Tutorial (one tutorial by id)
No prior experience with Angular Signals is assumed.
Step 1: Angular Signals vs RxJS: Fine-Grained Reactivity vs. Event Streams
To see where each tool belongs, start with what it was designed to solve. RxJS is built around values over time. When you subscribe to an Observable, you listen to a stream of pushed values that can emit zero, one, or many times, possibly for as long as the app is open.
A Signal represents the current value of some state. It always has a value you can read, and when that value changes, anything that read it is notified. Because Angular knows which signals a template reads, it can mark only the components that depend on a changed value for an update, instead of checking everything.
import { Component, signal, computed } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-bundle-summary',
standalone: true,
imports: [CurrencyPipe],
template: `
<p>Tutorials in bundle: {{ itemCount() }}</p>
<p>Total price: {{ totalCents() / 100 | currency }}</p>
<button type="button" (click)="addItem()">Add tutorial</button>
`
})
export class BundleSummaryComponent {
// A writable signal for local component state
itemCount = signal(0);
unitPriceCents = 2999;
// A computed signal derived automatically from itemCount
totalCents = computed(() => this.itemCount() * this.unitPriceCents);
addItem() {
this.itemCount.update(count => count + 1);
}
}
This component has no subscribe calls, no
cleanup in ngOnDestroy, and no
async pipe. You read a signal by calling it
like a function, such as itemCount(). The
currency pipe formats the amount as dollars
with the default US locale.
Expected result: the page starts at "Tutorials in bundle: 0".
After three clicks, it shows "Tutorials in bundle: 3" and "Total price:
$89.97". The totalCents signal is only
recalculated when something reads it after
itemCount has changed.
Step 2: When to Use Angular Signals
Signals fit best with synchronous, local state and values derived from it:
- Local UI state: toggling modals, expanding accordion panels, sorting table columns, or tracking the selected tab.
-
Computed values: filtering a list or calculating totals from other
state with
computed(). -
Component inputs: using
input()signals instead of@Input()decorators, so inputs can feed straight intocomputed(). - Template bindings: reading state directly in the template with no subscription to manage.
For these cases, Signals usually mean less boilerplate, and components are easier to test because state is plain values you can set and read.
Signals and Zoneless Angular
Signals are also what make zoneless Angular practical. With Zone.js, Angular
re-checks the app after almost any asynchronous task. Without it, Angular only
updates the view when it is notified, and reading a signal in a template,
using the async pipe, or calling
markForCheck() are all ways of notifying it.
In Angular 21 and later, new applications are zoneless by default. In Angular
20, you enable it yourself:
// app.config.ts (only needed on Angular 20; Angular 21+ new apps are already zoneless)
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(), provideZonelessChangeDetection()]
};
Older tutorials show
provideExperimentalZonelessChangeDetection().
That was the earlier name from Angular 18 and 19. To get the full benefit,
also remove zone.js from the
polyfills entries in
angular.json, since Angular's documentation
recommends removing it from the build to reduce bundle size. How much you gain
depends on your app. The documented benefits include easier debugging and
better compatibility with newer browser APIs, and small apps may see little
difference.
The examples in this article update the view through signals or the
async pipe, so they fit this model. The thing
to watch for is a plain class property changed inside a timer or callback.
Without Zone.js, nothing tells Angular the view is out of date, so write to a
signal instead.
Step 3: When to Keep RxJS (Async Streams and Operators)
Real applications spend a lot of time on events that happen over time. RxJS is still the better fit for:
-
Request flows with several steps:
HttpClientreturns Observables, so retries, combining requests, and interceptor-based flows are all built around RxJS operators. Unsubscribing from an HTTP Observable cancels the in-flight request, andswitchMapuses that to drop an outdated request when a new one starts. -
Debounced search inputs: handling fast keystrokes with
debounceTime,distinctUntilChanged, andswitchMapso you don't flood your API or show stale results. - WebSockets and real-time feeds: live tickers, chat messages, and notifications, where events keep arriving.
- Router events: reacting to navigation start and end, or to route parameter changes.
Here is the classic version of a debounced search, using only RxJS and the
async pipe:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { Tutorial } from './tutorial.model';
@Component({
selector: 'app-tutorial-search-rxjs',
standalone: true,
imports: [ReactiveFormsModule, AsyncPipe],
template: `
<input [formControl]="searchControl" placeholder="Search tutorials..." />
<ul>
@for (tutorial of searchResults$ | async; track tutorial.id) {
<li>{{ tutorial.title }}</li>
}
</ul>
`
})
export class TutorialSearchRxjsComponent {
private http = inject(HttpClient);
searchControl = new FormControl('', { nonNullable: true });
searchResults$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query =>
this.http.get<Tutorial[]>('/api/tutorials', { params: { q: query } }).pipe(
// If a request fails, show an empty list instead of ending the stream
catchError(() => of([] as Tutorial[]))
)
)
);
}
Here's what each piece does.
debounceTime(300) waits for a 300 ms pause in
typing. distinctUntilChanged() skips repeats
of the same text. switchMap starts a new
request for each new query and cancels the previous one, so an old, slower
response can't overwrite a newer one. Passing the value through
params lets
HttpClient build and encode the query string,
so a search for "c++" is sent correctly in current Angular versions. The
catchError sits inside
switchMap on purpose: if it were outside, one
failed request would end the whole stream.
Expected result: nothing is requested on page load, because
valueChanges only emits when the user types.
Typing "lap" quickly sends one request about 300 ms after you stop, and the
list shows whatever your API returns for "lap".
You could build the same behavior from signals and timers, but you'd have to
write the debounce timer and the request cancellation yourself. Angular 22
adds an experimental debounced() function
that debounces a signal and returns a resource. Because the Angular docs mark
it experimental, RxJS remains the safer choice for production search boxes
today.
Step 4: Using Signals and RxJS Together
In a mature codebase you rarely pick one. You connect them. Angular ships two
helpers in @angular/core/rxjs-interop:
toSignal() turns an Observable into a
read-only signal, and toObservable() turns a
signal into an Observable. The diagram below gives a quick way to decide which
side a piece of data belongs on.
toSignal() or
rxResource(), while synchronous local state
starts as a signal.
First, toSignal(). Following the usual advice
to keep HTTP calls in services, a service returns Observables and the
component reads one as a signal. This service is also used by the full example
in Step 6:
// tutorial.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Tutorial } from './tutorial.model';
@Injectable({ providedIn: 'root' })
export class TutorialService {
private readonly http = inject(HttpClient);
getFeatured(): Observable<Tutorial[]> {
return this.http.get<Tutorial[]>('/api/tutorials/featured');
}
search(query: string): Observable<Tutorial[]> {
return this.http.get<Tutorial[]>('/api/tutorials', { params: { q: query } });
}
}
// featured-tutorials.component.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TutorialService } from './tutorial.service';
@Component({
selector: 'app-featured-tutorials',
standalone: true,
template: `
@if (featured(); as list) {
<ul>
@for (tutorial of list; track tutorial.id) {
<li>{{ tutorial.title }}</li>
}
</ul>
} @else {
<p>Loading featured tutorials...</p>
}
`
})
export class FeaturedTutorialsComponent {
// Convert the Observable into a read-only signal.
// Its type is Signal<Tutorial[] | undefined>.
protected readonly featured = toSignal(inject(TutorialService).getFeatured());
}
toSignal() subscribes for you and
unsubscribes when the component is destroyed, so you don't write cleanup code.
Until the request finishes, featured() is
undefined, which is why the template has an
@else branch. An empty array still counts as
a value, so an empty list renders as an empty
<ul>.
Expected result: you see "Loading featured tutorials..."
briefly, then the list. If the request fails, reading
featured() throws the error, so handle
failures with catchError in the service or
use a resource (Step 5), which tracks errors for you.
The other direction is toObservable(), which
lets a signal feed an RxJS pipeline. Two timing details matter. The Observable
emits the signal's first value when subscribed, and later values only after
the signal settles, so several quick
set() calls in one tick produce a single
emission. That first emission is why a search pipeline needs a blank-input
check. Step 6 shows both directions in one working component.
Step 5: A Newer Option: resource, rxResource, and httpResource
For the common "load some data and show it" case,
toSignal() leaves you to track loading and
errors yourself. Angular's resource APIs do that for you.
resource() wraps a Promise-based loader,
rxResource() wraps an Observable-based one,
and httpResource() wraps
HttpClient, so interceptors still apply. In
Angular 22 all three are stable. A resource re-runs its loader whenever the
signals it reads change, and it exposes
value,
isLoading,
error, and
hasValue as signals.
// tutorial-detail.component.ts
import { Component, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { CurrencyPipe } from '@angular/common';
import { Tutorial } from './tutorial.model';
@Component({
selector: 'app-tutorial-detail',
standalone: true,
imports: [CurrencyPipe],
template: `
@if (tutorial.error()) {
<p>Couldn't load this tutorial.</p>
<button type="button" (click)="tutorial.reload()">Try again</button>
} @else {
@if (tutorial.value(); as t) {
<h2>{{ t.title }}</h2>
<p>Price: {{ t.priceCents / 100 | currency }}</p>
} @else {
<p>Loading tutorial...</p>
}
}
`
})
export class TutorialDetailComponent {
// A required signal input, set by the parent: <app-tutorial-detail [id]="1" />
readonly id = input.required<number>();
// Re-fetches automatically whenever the id input changes
protected readonly tutorial = httpResource<Tutorial>(() => `/api/tutorials/${this.id()}`);
}
The template checks error() first on purpose.
When a resource is in its error state, reading
value() throws, so the value is only read in
the non-error branch. In the loading state
value() is
undefined, which shows the loading message.
When id changes, the resource cancels any
request still in flight and starts a new one. Because
id is a number, it is safe to put in the URL;
if you build a URL from user-typed text, wrap it in
encodeURIComponent().
Expected result: "Loading tutorial..." first, then the title
and price. A failed request shows the error message with a retry button.
So where do resources fit? Use
httpResource() for straightforward data
loading driven by signals. Use
rxResource() when your data already comes
from an Observable-returning service. Keep plain RxJS for streams that emit
repeatedly or need several operators, such as the debounced search below.
Step 6: Putting It Together: a Codingvila Tutorial Catalog
Now we'll combine everything into one small app: a search box, a cart, and a
detail view. The rule of thumb is simple. Synchronous state (the cart, the
search text) lives in signals. Asynchronous work (the debounced search) lives
in RxJS. Data that just needs loading uses a resource. You already have
app.config.ts,
main.ts,
tutorial.model.ts,
tutorial.service.ts, and
tutorial-detail.component.ts from the earlier
steps. Three pieces remain.
First, the cart as a small signal-based store. The private signal can only be changed through the methods, and the public view is read-only:
// cart.store.ts
import { Injectable, computed, signal } from '@angular/core';
import { Tutorial } from './tutorial.model';
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<Tutorial[]>([]);
// Read-only view: components can read it but not change it
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().length);
readonly totalCents = computed(() =>
this._items().reduce((sum, item) => sum + item.priceCents, 0)
);
add(tutorial: Tutorial) {
// Ignore duplicates so one tutorial can't be added twice
this._items.update(items =>
items.some(item => item.id === tutorial.id) ? items : [...items, tutorial]
);
}
remove(id: number) {
this._items.update(items => items.filter(item => item.id !== id));
}
}
Second, the search component. The search text is a signal.
toObservable() sends it into an RxJS pipeline
that debounces, trims, cancels outdated requests, and handles errors.
toSignal() turns the result back into a
signal. Each outcome is one object with a
status, so the template can show loading,
error, and empty states without extra flags:
// tutorial-search.component.ts
import { Component, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { CurrencyPipe } from '@angular/common';
import { Observable, catchError, debounceTime, distinctUntilChanged, map, of, startWith, switchMap } from 'rxjs';
import { CartStore } from './cart.store';
import { Tutorial } from './tutorial.model';
import { TutorialService } from './tutorial.service';
interface SearchState {
status: 'idle' | 'loading' | 'success' | 'error';
items: Tutorial[];
}
const IDLE: SearchState = { status: 'idle', items: [] };
const LOADING: SearchState = { status: 'loading', items: [] };
const FAILED: SearchState = { status: 'error', items: [] };
@Component({
selector: 'app-tutorial-search',
standalone: true,
imports: [CurrencyPipe],
template: `
<label for="tutorial-search">Search Codingvila tutorials</label>
<input id="tutorial-search" type="search" [value]="query()" (input)="onInput($event)" />
<p aria-live="polite">
@switch (state().status) {
@case ('loading') { Searching... }
@case ('error') { Search failed. Please try again. }
@case ('success') { {{ state().items.length }} tutorials found }
}
</p>
<ul>
@for (tutorial of state().items; track tutorial.id) {
<li>
{{ tutorial.title }} ({{ tutorial.priceCents / 100 | currency }})
<button type="button" (click)="cart.add(tutorial)">Add to cart</button>
</li>
}
</ul>
`
})
export class TutorialSearchComponent {
private readonly tutorials = inject(TutorialService);
protected readonly cart = inject(CartStore);
// UI state lives in a signal
protected readonly query = signal('');
// The async work lives in RxJS, and the result comes back as a signal
protected readonly state = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
map(q => q.trim()),
distinctUntilChanged(),
switchMap((q): Observable<SearchState> =>
q
? this.tutorials.search(q).pipe(
map((items): SearchState => ({ status: 'success', items })),
startWith(LOADING),
catchError(() => of(FAILED))
)
: of(IDLE) // blank input: no request
)
),
{ initialValue: IDLE }
);
protected onInput(event: Event) {
this.query.set((event.target as HTMLInputElement).value);
}
}
A few details are worth noticing. The
catchError sits inside
switchMap, so one failed search doesn't kill
later searches. startWith(LOADING) emits a
loading state the moment each request begins. The blank-input branch matters
because toObservable() also emits the
starting value, and without it the app would send an empty search on page
load. The aria-live="polite" attribute lets
screen readers announce the status text.
Third, the cart summary and the root component that assembles the app:
// cart-summary.component.ts
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartStore } from './cart.store';
@Component({
selector: 'app-cart-summary',
standalone: true,
imports: [CurrencyPipe],
template: `
<h2>Your cart</h2>
<p>{{ cart.count() }} tutorial(s), total {{ cart.totalCents() / 100 | currency }}</p>
<ul>
@for (item of cart.items(); track item.id) {
<li>
{{ item.title }}
<button type="button" (click)="cart.remove(item.id)">Remove</button>
</li>
}
</ul>
`
})
export class CartSummaryComponent {
protected readonly cart = inject(CartStore);
}
// app.component.ts
import { Component } from '@angular/core';
import { CartSummaryComponent } from './cart-summary.component';
import { FeaturedTutorialsComponent } from './featured-tutorials.component';
import { TutorialDetailComponent } from './tutorial-detail.component';
import { TutorialSearchComponent } from './tutorial-search.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [FeaturedTutorialsComponent, TutorialSearchComponent, TutorialDetailComponent, CartSummaryComponent],
template: `
<h1>Codingvila Tutorials</h1>
<app-featured-tutorials />
<app-tutorial-search />
<app-tutorial-detail [id]="1" />
<app-cart-summary />
`
})
export class AppComponent {}
Because the cart logic is plain signals, it's easy to test without any HTTP setup:
// cart.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartStore } from './cart.store';
describe('CartStore', () => {
it('adds a tutorial only once and totals the price in cents', () => {
const store = TestBed.inject(CartStore);
const tutorial = { id: 1, title: 'Angular Signals for Beginners', priceCents: 999 };
store.add(tutorial);
store.add(tutorial);
expect(store.count()).toBe(1);
expect(store.totalCents()).toBe(999);
});
});
Expected result: with a backend that implements the three
sample routes, the page shows featured tutorials, a detail panel for tutorial
1, and an empty cart. Typing in the search box shows "Searching..." about 300
ms after you stop, then the matching tutorials. Clicking "Add to cart" updates
the cart count and total immediately, and adding the same tutorial twice
leaves one entry. To change which tutorial the detail panel shows, change the
id value passed from the parent; the resource
fetches the new one automatically.
Two limits to keep in mind. This sample has no authentication, caching, or
pagination, which a real catalog would need. And the cart lives in memory, so
it resets on page reload; persist it (for example, to local storage from an
effect()) if you need it to survive a
refresh.
Common Pitfalls and Anti-Patterns
Anti-pattern: using effect() to keep state
in sync
Developers coming from React sometimes treat
effect() like
useEffect and use it to copy one signal into
another. The official guidance is to avoid effects for propagating state
changes, because it makes data flow hard to follow and can cause surprising
update cycles. Use computed() for derived
state, and linkedSignal() for state that
depends on another signal but can also be set directly. Keep
effect() for syncing to non-signal APIs, such
as logging, analytics, or writing to local storage.
Pitfall: calling toSignal() outside an
injection context
toSignal() and
toObservable() need an injection context.
Field initializers and constructors have one. An arbitrary method or event
callback does not, and calling them there throws a runtime error. In those
cases, pass an Injector through the options
object.
Pitfall: calling
toSignal() repeatedly
Each call creates a new subscription. If you call it inside a method or getter, every call subscribes again, and an HTTP Observable would send a new request each time. Create the signal once, usually as a field, and reuse it.
Pitfall: an erroring Observable behind
toSignal()
If the source Observable errors, reading the signal throws that error. Handle
failures with catchError before converting,
as in the search example, or use a resource, which stores the error for you.
Pitfall: reading a resource's
value() while it is in an error
state
Reading value() in the error state throws.
Check error() or
hasValue() first, as the detail component
does.
Pitfall: changing plain properties in a zoneless app
Without Zone.js, a plain class property updated inside a timer or callback won't refresh the view, because nothing notifies Angular. Store that value in a signal instead.
Best Practices for Larger Angular Codebases
-
Keep HTTP in services: put
HttpClientcalls in services, and expose them as Observables or resources depending on the use case. -
Expose read-only signals: publish public state with
asReadonly()and keep the code that changes it inside store methods, asCartStoredoes. -
Use
input()andmodel()in new components: signal-based inputs and two-way bindings cut down on decorator boilerplate and plug straight intocomputed(). - Keep multi-step async flows in RxJS: races, retries, and merging several streams are what its operators are for.
- Don't rewrite working RxJS code just to remove it: migrate where Signals simplify the code, and leave stable streams alone.
Frequently Asked Questions
Do Signals replace RxJS in Angular?
No. Signals cover synchronous state, while RxJS covers values that arrive over time. Angular keeps both, and its interop package exists so you can combine them.
Should I convert every Observable to a signal with toSignal()?
No. Convert an Observable when a template or a
computed() needs its latest value. If an
Observable only feeds other operators or triggers side effects, leave it as an
Observable.
Can I debounce a signal without RxJS?
In Angular 22 there's an experimental
debounced() function that returns a resource.
Because it's experimental, check the current docs before using it in
production. The RxJS approach in Step 3 is stable.
Do I still need the async pipe?
Not for new code that uses toSignal() or
resources. The async pipe still works and is
fine for existing templates.
Do I need Zone.js to use Signals?
No. Signals work with or without Zone.js, and they are a good fit for zoneless apps because reading a signal in a template tells Angular when to update.
Conclusion
Signals and RxJS solve different problems. Signals hold synchronous component
state and drive template updates with little code, which also makes them a
natural fit for zoneless Angular. RxJS handles anything that unfolds over
time, including typing, sockets, and multi-step request flows. Convert with
toSignal() where a template reads the result,
use toObservable() when a signal needs to
feed an RxJS pipeline, and reach for
httpResource() when you simply need data with
loading and error state.
As a next step, find a component in your codebase that subscribes manually
just to show a value in its template. Move that value to a signal,
toSignal(), or a resource, and leave the rest
of your RxJS in place.
