Angular Signals vs. RxJS: Where Each One Belongs in Real-World Enterprise Apps

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.

Infographic comparing Angular Signals for synchronous state with RxJS for asynchronous event streams, connected by toSignal and toObservable.

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(), and toObservable() became stable in v20, and signal input() 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, so standalone: true is 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:

typescript
// 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.

typescript
// 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.

typescript
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 into computed().
  • 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:

typescript
// 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: HttpClient returns 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, and switchMap uses that to drop an outdated request when a new one starts.
  • Debounced search inputs: handling fast keystrokes with debounceTime, distinctUntilChanged, and switchMap so 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:

typescript
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.

Decision flowchart: signal or RxJS Starting with a new piece of data, ask whether it is an async event stream. If yes, keep it in RxJS, and if a template needs it, expose it with toSignal or rxResource. If no, use a signal. Start: new data Is it an async event stream? Yes No Keep it in RxJS search, sockets, flows Use a signal toggles, totals, inputs template needs it? toSignal() or rxResource() expose the result as a signal
Decision flowchart: asynchronous streams stay in RxJS and reach the template as signals through 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:

typescript
// 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 } });
  }
}
typescript
// 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.

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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 HttpClient calls 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, as CartStore does.
  • Use input() and model() in new components: signal-based inputs and two-way bindings cut down on decorator boilerplate and plug straight into computed().
  • 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.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com