Will Signals Replace RxJS?

Short answer: No, not entirely.

Although Angular isn't the pioneer in adopting Signals, it's the framework where RxJS has the deepest roots. This naturally sparks questions about whether Signals will eventually crowd out RxJS in the Angular world.

This is what I refer to as the Spoon-and-Fork dilemma—a familiar pattern in software development.

Historically, spoons came before forks. When forks arrived, some considered them a direct substitute—until someone tried eating soup with one.

Whenever two technologies address a shared set of problems, the newer one is often prematurely seen as a complete substitute for its predecessor.

RxJS excels at time-based manipulation of push-based streams—think buffering, throttling, retries, exponential backoff, and orchestration through flattening.
But it isn't ideal for modeling the reactive graph between a mutable value and the Angular views that render it.

That's where Signals step in, simplifying how change propagation reaches the view layer.

For a thorough Signals deep dive, check out this post by my colleague Tomas Trajan, or my own deep-dive into the internals if you prefer 😅.

RxJS brings a time dimension, but that comes with overhead: you need operators (like map) and Angular pipes (e.g. async and push) to propagate changes to the DOM.

Signals, by contrast, lack stream-awareness and time-awareness, yet they shine at constructing a reactive graph and pushing changes from value mutations through computed signals into the view.

Let Signals Take the Lead

Even though Signals and RxJS overlap, there's room for each to perform at its finest.
Unless you never care about progress indicators, retries, or timed events, RxJS remains useful—except when you get to the view layer.

What truly matters in a component is its present state—a component exists in the moment—and Signals embody that present state.

Why not simply map the observable's current value into a Signal via toSignal() and call it done?

But hold on.

What if an error is thrown?

And how can we tell whether the observable hasn't emitted yet?

What about streams that produce multiple values—do we know if they've completed?

Spinner Logic Traps

Imagine fetching data from a remote API. We [convert the Observable into a Signal](#) and [hand it to a child component](#).

@Component({
  
  template: `<mc-recipe [recipe]=”recipe()”/>`
})
class MyCmp {
  recipe = toSignal(this.getRecipe());

  getRecipe() {
    return of('🍔').pipe(delay(1000));
  }
}

A Familiar Typing Challenge

error TS2322: Type 'string | undefined' is not assignable to type 'string'

The child expects a string input, but by default toSignal() doesn't force the observable to emit before reading the Signal. It falls back to an initial value of undefined, unless you pass an initialValue.

You might end up with two unattractive options: force an initial value we don't want:

recipe = toSignal(this.getRecipe(), {initialValue: '🍕'});

Or accept the default behavior…

recipe = toSignal(this.getRecipe());

…and then account for rendering a spinner in the template until data arrives:

<mc-spinner *ngIf="!recipe()"/>
<mc-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" />

Now imagine the source returns null, undefined, or an empty string for whatever reason.

Your spinner keeps spinning—you can't distinguish between a pending status and a value that coincidentally equals the initial state.

This also introduces issues when using *ngIf + as—boolean coercion can have unintended consequences.

When Legitimate Data Gets Flagged as Invalid

Errors are part of the deal, and we may need to catch them.

If the observable errors out, the Signal (and any computed signals based on it) will throw on read.
Since Signals are typically consumed in templates, error handling becomes awkward.

An obvious fix is to leverage catchError() and [set an error Signal](#) via side effects.

@Component({
  
  template: `
    <div *ngIf="error()">Oups! Something went wrong.</div>
    <mc-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" />
  `,
})
export class AppComponent {
  error = signal(null);
  recipe = toSignal(
    this.getRecipe().pipe(
      catchError((error) => {
        this.error.set(error);
        return of(null);
      })
    )
  );

  
}

The downside is that side effects often create mismatches. Once we manually set an error signal, we must also reset it—otherwise, watch what happens:

RxJS to Signal Error

The error persists even when we navigate to another recipe.

To fix that, we'd add more procedural logic—or wait. Suspense!

There's More

Don't forget that observables can push repeatedly! Imagine getRecipe() still hits a remote endpoint, but it first emits a cached result before the fresher network response arrives.

Even with the initial cached value, showing a progress indicator until the stream completes makes sense—users should know we're fetching more data.

The state isn't pending, nor done—it sits somewhere in between.

Here's the rub: the Signal "swallowed" the complete notification, leaving no built-in way to tell if the stream finished.
Instead of resolving to [the finalize() trick](#) combined [with a signal](#)—which reintroduces the same inconsistency—we need a more thoughtful solution.

@Component({
  
  template: `
    <mc-progress-bar *ngIf="!finalized()"/>
    <app-recipe *ngIf="recipe() as recipeValue" [recipe]="recipeValue" />
  `,
})
export class AppComponent {
  finalized = signal(false);
  recipe = toSignal(
    this.getRecipe().pipe(
      finalize(() => this.finalized.set(true))
    )
  );

  
}

Meet Suspensify

We need a way to turn observables into a richer Signal type that holds a coherent snapshot of the observable's current state.

The materialize operator sounds plausible, but it falls short:

  • Before the first emission, error, or completion, nothing arrives—so we'd have to inject an initial "started" notification.
  • Completion doesn't carry the last value along, so you're stuck tracking it manually—e.g. with scan.

This isn't just a Signal problem. The challenge also appears when mapping async status into NgRx effects or via RxAngular's connect method.
That's why, together with Edouard Bozon, we'd built the suspensify() operator some time back. See: https://github.com/jscutlery/devkit/tree/main/packages/operators

This operator yields an Observable with an immediate initial value and guarantees it never errors.

Instead of passing along raw values, it emits a Suspense object with details about the observable's current status.

Take this example:

this.getRecipe()
  .pipe(suspensify())
  .subscribe(value => console.log(value));

If the observable emits then fails, we'd see these notifications:

{pending: true, finalized: false, hasError: false, hasValue: false}
{pending: false, finalized: false, hasError: false, hasValue: true, value: '🍔'}
{pending: false, finalized: true, hasError: true, hasValue: false, error: '💥'}

This can be turned into a Signal that stays initialized and never throws.

recipe = toSignal(this.getRecipe().pipe(suspensify())); // Signal<Suspense<Recipe> | undefined>

Trying this in a template will quickly produce a type error:

<!-- error TS2532: Object is possibly 'undefined' -->
<mc-progress-bar *ngIf=”!recipe().finalized”/>

Signals default to undefined, but we can tell toSignal() that our observable synchronously emits an initial value thanks to suspensify():

recipe = toSignal(this.getRecipe().pipe(suspensify()), {requireSync: true}); // Signal<Suspense<Recipe>>

Template Type Narrowing

By default, suspensify() returns a discriminated union to aid type narrowing.

Here's what that looks like:

<div *ngIf="suspense.hasError">
  {{ suspense.error }} // ✅
  {{ suspense.value }} // 💥 template compilation error
</div>

<div *ngIf="suspense.hasValue">
  {{ suspense.error }} // 💥 template compilation error
  {{ suspense.value }} // ✅
</div>

This avoids typical mishaps and lets us pass precise types to children, but it doesn't work smoothly with Signals—the compiler can't guarantee recipe() remains stable during Change Detection.

<div *ngIf="recipe().hasError">
  {{ recipe().error }} // 💥 template compilation error
  {{ recipe().value }} // 💥 template compilation error
</div>

<div *ngIf="recipe().hasValue">
  {{ recipe().error }} // 💥 template compilation error
  {{ recipe().value }} // 💥 template compilation error
</div>

That's on the Angular roadmap—some future release should address it, at least for signal-based components.

Until then, flip strict to false in suspensify; then error and value are always present, albeit possibly undefined.

recipe = toSignal(this.getRecipe().pipe(suspensify({strict: false})));
<div *ngIf="recipe().hasError">
  {{ recipe().error }} // ✅
  {{ recipe().value }} // ✅
</div>

<div *ngIf="recipe().hasValue">
  {{ recipe().error }} // ✅
  {{ recipe().value }} // ✅
</div>

You could also create a local alias via ngIf + as:

<ng-container *ngIf=”recipe() as suspense>
  <div *ngIf="suspense.hasError">
    {{ suspense.error }} // ✅
    {{ suspense.value }} // 💥
  </div>

  <div *ngIf="suspense.hasValue">
    {{ suspense.error }} // 💥
    {{ suspense.value }} // ✅
  </div>
</ng-container>

Although it improves narrowing, it's clunky—and per Signals RFC, that trick likely won't carry over to signal-based components. The upside? Signal-based components are expected to include native template type narrowing.

A Reusable Helper

Eventually, we can package this into a [small utility function](#):

@Component({
  
  template: `
<mc-progress-bar *ngIf=”!recipe().finalized”/>

<div *ngIf="recipe().hasError">
  {{ recipe().error }}
</div>

<div *ngIf="recipe().hasValue">
  {{ recipe().value }}
</div>
`
})
class MyCmp {
  recipe = toSuspenseSignal(this.getRecipe());

  getRecipe(): Observable<Recipe> {
    
  }
}

function toSuspenseSignal<T>(source$: Observable<T>) {
  return toSignal(source$.pipe(suspensify({ strict: false })), {
    requireSync: true,
  });
}

Takeaway Points

  • 🤝 Signals complement—not replace—Observables.
  • 💪 A single operator like suspensify() preserves a declarative flow, minimizing randomness.
  • 🐙 suspensify() works well with NgRx effects or RxState to combine several sources.

Resources and Training

👨🏻‍🏫 Workshops

📦 Suspensify Operator

💻 Source on Stackblitz

📰 Subscribe to Newsletter

💬 Discuss on GitHub