Mastering Angular Signals: A Complete Guide Beyond RxJS


There’s only one thing that Signals can’t do like RxJs. Otherwise, Signals are by far the better choice in Angular.

I’ve been using Signals a lot lately, and quite extensively.

And honestly, it’s such a pleasure to work with — it’s much more accessible than RxJs.

Signals integrate much better into the framework than RxJs does (for example, inputs, viewChild… now return signals).

But before going any further, I have no issue working with RxJs.

However, that’s not the case for most developers who are just starting out with Angular or who don’t want to fully commit to learning RxJs.

So, I set out on a quest to make the more advanced RxJs patterns accessible using Signals.

At first, it wasn’t easy at all — but over time, I found Signal-based equivalents for many RxJs patterns that I liked.

I’ve often talked about this in my LinkedIn posts.

But there was still one last point — one reason why RxJs kept a small advantage when designing an app, something that Signals didn’t have.

Most articles and examples I’ve seen on the topic don’t go that far with Signals and haven’t yet encountered this final challenge.

Here’s what I thought was impossible to do with Signals.

In RxJs, there’s the BehaviorSubject, whose behavior is quite similar to that of a Signal.

And there’s the Subject, which doesn’t have an equivalent in Signals.

Yet, from my point of view, the behavior provided by a Subject is practically indispensable in an application.

It allows a state to react to an event (e.g., when userLogout is triggered, some states should be reset) and to trigger a change only when that event emits a value.

Some states that react to user logout event diagram

That’s the essence of event-driven architecture. And declarative code.

Why is this kind of behavior necessary?

Because when multiple states need to be updated after an event, you’d otherwise have to trigger each one manually — and it’s easy to forget one.

Very quickly, you can end up with inconsistent states.

Whereas if each state can declaratively react to an event on its own, it’s much easier to maintain and to understand what’s happening.

But until recently, I thought it was impossible to implement such event-driven behavior with Signals — yet after some experimentation, I now believe it actually is.

After much reflection, I think I’ve found an equivalent approach to what can be done with RxJs, and for me, it’s perfectly fine.

The mindset is different — it’s tied to the pull-based update mechanism of Signals versus the push-based nature of RxJs.

I won’t go too deep into that technical aspect — it’s interesting, but I feel like it can limit creativity in how we use Signals to replace RxJs.

This article will help you to understand what is possible to do and how I think Signals can be used to create Angular application without friction.

This article will follow this plan:

  1. 7 Powerful tips after hundreds of hours mastering advanced signal patterns (part 1)

  2. Transforming an Angular RxJs example into signals (part 1)

  3. Build a truly event-driven architecture using only signals (part 2)

  4. Does rxjs still matter in a signal-first angular world? (part 2)

  5. Essential utilities and what’s still missing in signals today (part 3)

  6. Effects in signals — game-changer or hidden trap? (part 3)

  7. Create a debounce signal utility without rxjs — step by step (part 3)

As you can see, I won’t explain how signal, computed, linkedSignal, or effect work — there are plenty of articles that already cover that.



7 Powerful tips after hundreds of hours mastering advanced signal patterns

RxJs versus Signal

RxJs is not the perfect tool for declarative pattern, Signals does better.

I have a created ng-query a server state management tools similar to TanStackQuery, but that only relies on Signal (RxJs is fully optional).

I learn a lot on how Signals can be used.

And here the most importantes things that I learn and you can use to implements Signals without friction.

(Of course, it is opinionated 😉)



1 – Your code should use declrative patterns.

Using imperative code is very bad when working with Signals.

You’ll quickly end up writing spaghetti code just to achieve your goals.

One of the biggest advantages of Signals actually comes from their limitations — such as the resource and effect functions, which are essential (as we’ll see in more detail later). They enforce writing code within a reactive context (similar to the injection context).

Whereas RxJs allowed code to be written “anywhere,” which encouraged imperative patterns.

onSubmit(form: MyForm) { 
 this.isLoading = true;
 this.myApiService.save$(form.value).subscribe(() => {
     this.isLoading = false;
  })
} 
Enter fullscreen mode

Exit fullscreen mode

In this example, which I’m sure you’ve already seen:

  • There’s no error handling
  • If onSubmit is triggered twice in a row, it will cause unwanted parallel requests
  • The isLoading management is imperative, making the code more complex than it should be
  • You can’t react to it directly (you have to manually call a function or emit an event)

(Of course, there are better RxJs patterns to avoid these issues — I’ve shared some on this blog.)

With Signals and the use of Resources, the ways of doing things are more limited, which enforces a declarative coding approach.

saveForm = signal<MyFormType | undefined>(undefined)
saveResource = resource({
  params: this.saveForm,
  loader: ({params: form}) => this.myApiService.save(form)
})
Enter fullscreen mode

Exit fullscreen mode

PS: Here, I’m using a resource to perform a mutation — and it’s totally fine to do it this way.

Advantages:

  • Declarative code
  • Handles all asynchronous states: loading, resolved, etc.
  • Allows deriving other states easily

ex:

saveResource = resource(...)
dataResource = resource(...)

// 👇 derived state
isLoading = computed(() => dataResource.status() === 'loading' || saveResource.status() === 'loading')
Enter fullscreen mode

Exit fullscreen mode

Indirectly, Signals and their utilities force you to write declarative code, unlike RxJs, which doesn’t impose such limitations — something I personally see as more of a drawback than an advantage in Angular.



2 – Signals promote State-Driven Architecture

When working with Signals and using a declarative pattern, you should design your app with a state-driven development mindset.

This means you should use computed or linkedParams to react to changes in another state.

If you’re familiar with event-driven development, it’s very similar — but instead of referencing an event, you directly reference the source state.

class UserService {
  readonly userResource = resource({
   loader: () => this.api.getUser()
  });

  readonly userId = computed(() => this.userResource.hasValue() ?  this.userResource.value().id : undefined);
}

class ProductService {
  readonly productsResource = resource({
   params: this.userService.userId
   loader: ({params: userId}) => this.api.getProduct(userId)
  })
}

class ChatMessagesService {
  readonly currentChatId = signal<string | undefined>(undefined);

  readonly chatMessagesResource = resource({
   params: this.userService.userId
   loader: ({params: userId}) => this.api.getChatMessage(userId)
  })

  readonly currentChat = computed(() => this.chatMessagesResource.hasValue() ? this.chatMessagesResource.value()[this.currentChatId] : undefined)
}
Enter fullscreen mode

Exit fullscreen mode

It’s so much easier to understand the data flow of the whole app with this pattern.

Even when defining TypeScript types, in this small example, most of the types are declared at the backend API call level.

👉 Yes, you can also implement this pattern with RxJs. But I haven’t seen many articles showing this kind of example.

You’ll sometimes need to implement an event-driven architecture — I’ll show you how later in this article.



3 – Track all async processes like Resources do

💬 Signals are synchronous; they are not designed to handle async operations.

👆 That’s wrong (though maybe there are technical limitations I haven’t encountered yet).

Resources provide a way to track asynchronous processes by exposing their status and value through a signal.

So, my take is that for every async operation (query, mutation, debounce, waiting, etc.), you should always wrap the code to track both the async status and its value.

This is the key to working properly with Signals.

Instead of writing:

const search = signal("");
const isLoading = signal(false);
const hasError = signal(false);
const items = toSignal(
  toObservable(search).pipe(
    debounceTime(300),
    tap(() => {
      isLoading.set(true);
      hasError.set(false);
    }),
    switchMap((searchValue) => fetchItems(searchValue)),
    tap({
      next: () => isLoading.set(false),
      error: () => {
        isLoading.set(false);
        hasError.set(true);
      },
    })
  ),
  {
    initialValue: [],
  }
);
Enter fullscreen mode

Exit fullscreen mode

Does something like:

const search = signal("");

const debouncedSearch = decounce(search, 300);

const itemsResource = resource({
  params: () => debouncedSearch.status === 'resolved' ? search() : undefined,
  loader: ({ params: searchValue }) => fetchItems(searchValue),
});
Enter fullscreen mode

Exit fullscreen mode

Where RxJs promotes writing all logic in a single pipe, Signals encourage breaking down each step.

It’s possible to create a similar pipe for Signals, but it should be done intentionally — because the pipe pattern tends to hide useful information you might want to access.

In the second example, it becomes possible to display a custom icon to show the user that the current search is debouncing.

This approach also perfectly anticipates future Angular rendering improvements.

I believe the goal is to re-render only the part of the DOM where a Signal has changed (although, for now, Angular still re-renders the whole component where the Signal changed, even with ChangeDetection.OnPush).



4 – The undefined keyword is important

The undefined keyword is very important when working with Signals.

If you don’t already know, when passing an undefined value to a resource, it puts the resource into the idle state and prevents the resource loader from triggering.

dataResource = resource({
  params: () => undefined,
  loader: ({params}) => this.myApiService.getData(params)
})

dataResource.status(); // 'idle', will never change because params is undefined
Enter fullscreen mode

Exit fullscreen mode

Keep this in mind — the Angular team intentionally chose this behavior, and it’s quite intuitive.

It’s somewhat similar to the EMPTY keyword in RxJs, which stops the stream execution (without completing it), meaning the next steps won’t run until another valid observable is emitted instead of EMPTY.

For more advanced patterns, you might want to wrap most Angular Signal utilities to avoid reacting to specific keywords (by using a Symbol, for example).

So, when creating your own Signal utilities, you may want to replicate this kind of behavior.

When the source is undefined:

  • Do not trigger the process
  • Set it to an idle state (for async processes)



5 – Do not expose Effect code

You may need to use effect, and that’s totally fine — especially if you follow my guidelines.

Sometimes, effect is irreplaceable; even toObservable uses it.

And it works great — I once had a case with nested effects, and I couldn’t believe everything was properly destroyed, preventing any memory leaks.

Don’t be afraid to use an effect; it’s actually safer than a subscription.

Because you’re forced to use it within a reactive context, Angular will automatically handle its cleanup correctly.

Unlike RxJs subscriptions, which can be created outside the reactive context and require the developer to manage the unsubscription manually.

It’s essential for creating utilities like debounce and turning imperative code into declarative code.

Nonetheless, I believe every effect function should be wrapped inside a utility function.

Unless you’re simply logging something, or triggering a side effect like opening a modal, exposing an effect directly in a component or service might be a sign of poor practice.

“triggering a side effect like opening a modal”, it often depends on you modal API. Some can be fully declarative.
But keep in mind, that you should able to easily know if the a modal is open or not.

I know it’s not always easy — but keep that in mind.



6 – Actually you will need to write imperative code

If you follow this advice and wrap all your async code in a resource-like utility function, you may need to write some imperative code.

If you rely only on signals, handling async operations might force you to create one or more signals to store both the value and the status.

Within such a function, you’ll need to update your signals imperatively to reflect the current value and status.

And that’s totally fine, this imperative code is scoped to a relatively small function and, in theory, won’t expand beyond that.

Here are some examples that abstract away the imperative code:

const randomValueAfter5sRef = randomValueAfter5s(); //👈 Their is imperative code wrapped inside randomValueAfter5s
randomValueAfter5sRef.status() // 'loading', 'resolved'

function randomValueAfter5s() {
 const value = signal<number | undefined>();
 const status = signal<'loading' | 'resolved'>('loading');

 setTimeout(() => {
    value.set(Math.random()); // imperative code
    status.set('resolved'); // imperative code
 }, 5000);

 return {
   value,
   status
 }
}
Enter fullscreen mode

Exit fullscreen mode

const randomValueAfter5sRef = randomValueAfter5s();
randomValueAfter5sRef.status() // 'loading', 'resolved'

function randomValueAfter5s() {
 const value = signal<number | undefined>();
 const status = signal<'loading' | 'resolved'>('loading');

 setTimeout(() => {
    value.set(Math.random()); // imperative code
    status.set('resolved'); // imperative code
 }, 5000);

 return {
   value,
   status
 }
}
Enter fullscreen mode

Exit fullscreen mode

Here’s another example:

  • Firstly: It may look weird (I am lacking imagination 😂, so I may change it, and I wan to keep the debounce implementation for the final part)
  • It will open a modal 2s after initialization
  • It will also open the modal 2s after the input change

The behavior may look strange (it is not the same delay behavior as the delay operator from RxJs).

Stackblitz link

When looking at the component, all the code is declarative (except opening/closing the modal).

id="search" [value]="search()" (input)="search.set($any($event.target).value)" />

delayedSearch ({{ delayedSearch.status() }}): {{ delayedSearch.value() }}

Modal status ({{ modalHandler.status() }}): {{ modalHandler.value() }}

#modal class="modal">

Text {{ modalHandler.value() }}

Enter fullscreen mode

Exit fullscreen mode

export class App {
  private readonly modalref = viewChild<ElementRef<HTMLDialogElement>>('modal');
  protected readonly modalHandler = modalHandler(this.modalref); // 👈 declarative code

  protected readonly search = signal('Initial value'); // 👈 declarative code
  protected readonly delayedSearch = delay(this.search, 2000); // 👈 declarative code

  // 👇 Exposing an effect here is ok, due to the current Modal API
  private readonly _OpenModalAfterDelayEffect = effect(() => {
    // 👇 The status value will be used to trigger the effect callback
    if (this.delayedSearch.status() !== 'resolved') {
      return;
    }

    // 👇 use untracked to avoid to trigger the effect callback when the value change
    untracked(() => {
      this.modalHandler.open(this.delayedSearch.value() ?? ''); // 👈 imperative code
    });
  });
}
Enter fullscreen mode

Exit fullscreen mode

It respects all the previous points.

The effect and the imperative code are wrapped inside a utility function.

Except for the effect used to open the modal, which is fine here, as it mostly depends on your modal API. But thanks to the modalHandler we can track the modal status.

Delay:

export function delay<T>(source: () => T, ms: number) {
  const value = signal<T | undefined>(undefined);
  const status = signal<'loading' | 'resolved'>('loading');
  let timeout: ReturnType<typeof setTimeout> | null = null;

  // 👇 encapsulated effect
  effect(() => {
    const _sourceValue = source(); // track the sourceValue change
    if (timeout) return;

    status.set('loading');

    timeout = setTimeout(() => {
      value.set(source()); // 👈 imperative code and use to trigger the effect callback
      status.set('resolved'); // 👈 imperative code
      if (timeout) {
        clearTimeout(timeout); // 👈 imperative code
        timeout = null; // 👈 imperative code
      }
    }, ms);
  });

  return {
    value: value.asReadonly(),
    status: status.asReadonly(),
  };
}
Enter fullscreen mode

Exit fullscreen mode

modalHandler:

function modalHandler(
  modalRef: Signal<ElementRef<HTMLDialogElement> | undefined>
) {
  const value = signal('');
  const status = signal<'closed' | 'opened'>('closed');

  // 👇 encapsulated effect
  effect(() => {
    if (status() === 'opened') {
      modalRef()?.nativeElement.showModal();
    } else {
      modalRef()?.nativeElement.close();
    }
  });

  // 🔆 In the next article part, we will see how to use signal to implements events and remove close/open imperative api
  return {
    value: value.asReadonly(),
    status: status.asReadonly(),
    close: () => status.set('closed'), // 👈 imperative code
    open: (text: string) => {
      value.set(text); // 👈 imperative code, ok since the function is small and encapsulated
      status.set('opened'); // 👈 imperative code, ok since the function is small and encapsulated
    },
  };
}

Enter fullscreen mode

Exit fullscreen mode



7 – Debugging

From my experience, debugging Signal issues is harder than debugging RxJs.

When it runs into an infinite loop, it can be tricky to identify the cause of the problem.

Here are a few small tips that might help you:

  • Try using Angular DevTools to inspect the Signal graph.
  • Avoid reacting to changes in an entire object; instead, react only when specific properties change.

Instead of:

const myComplexeDerivedObject = computed(() => {
  const complexObjectValue = complexObject();
  ...
});
Enter fullscreen mode

Exit fullscreen mode

Do:

const complexObjectId = computed(() => complexObject().id);
const myComplexeDerivedObject = computed(() => {
  const _complexObjectIdValue = complexObjectId(); 
  // Only react when the id changes, not when other properties of the complexObject do
  untracked(() => {
    const complexObjectValue = complexObject();
    ...
  })
})
Enter fullscreen mode

Exit fullscreen mode

  • Add an equality function and use console.log or a debugger.
const myComplexeDerivedObject = computed(() => {
  const complexObjectValue = complexObject();
  ...
}, {
  equal: (cur, prev) => { 
      console.log({cur, prev});
      debugger;
      return cur === prev; // default mode
    }
});
Enter fullscreen mode

Exit fullscreen mode

  • Try wrapping most of your code in untracked, even in places where it might not seem necessary to make it non-reactive.

I once had an issue with an infinite Signal update because I had Signals inside a linkedSignal computation that changed and re-triggered the computation at the same time. I believe that’s not the intended behavior.

Even though I find debugging Signals harder, I’ve encountered far fewer issues compared to working with RxJs and I need less knowledge to fix the problem.



Transforming an Angular RxJS Example into Signals

While writing this article, I came across a post on X shared by Miloš Krstić (@grizlizli).

The example shows some RxJS code that he used to:

  • open a confirmation dialog,
  • if confirmed by the user, delete the targeted entity,
  • and then remove that entity from the resource used to fetch the list of entities.

The code looks like this (I simplified it a little bit):

  entitiesResource = resource(...);

  protected remove(entity: Entity): void {
    this.dialog()
      .open(RemoveEntityModalComponent, {
        data: { entity }
      })
      .afterClosed()
      .pipe(
        filter(confirmed => confirmed),
        switchMap(() => this.entityService.deleteEntity$(entity.id)),
        takeUntilDestroy(this.destroyRef)
      )
      .subscribe(() => {
        this.entitiesResource.update(entities => entities?.filter(e => e.id !== entity.id));
      });
  }
Enter fullscreen mode

Exit fullscreen mode



My Analysis Based on the Previous Part of the Article

The example looks pretty solid, and the author properly handles the unsubscription.

Here, I’ll propose another approach based on Signals, following the same principles as before.

Here’s what could be improved:

  1. The main weakness of the example is that there’s nothing preventing remove from being called multiple times (unless the dialog itself blocks this behavior).
  2. The RemoveEntityModalComponent is eagerly loaded (it could be lazy-loaded, but I won’t cover that here).
  3. If the user refuses to delete the entity, the subscription persists until the destroyRef is triggered. This may be harmless, but it still represents a minor memory leak.
  4. The user has no way to know if a deletion is currently in progress (especially if it’s asynchronous).
  5. The current setup only allows deleting one entity at a time — which might be fine, but still limiting.
  6. The subscription is inside a method, which hides the status of the async process.
  7. The code does not handle errors when deletion fails.
  8. An optimistic deletion approach could improve the user experience (though it might not be strictly necessary).
  9. The code doesn’t account for fast loading, which can cause layout shifts — for example, when a loader briefly appears before the final data is displayed. This hurts the UX.
  10. The code doesn’t offer an easy way to persist the resource value (e.g., in localStorage). That could be helpful if the user refreshes the page while keeping the data visible.
  11. Finally, it doesn’t provide an easy way to prefetch data.

I know some of these points may go beyond the original scope, but I’ll show how to address them using a proper tool.



Converting the RxJS Example to a Signal-Based Solution

Here’s a small improvement that can be achieved using Signals:

  entitiesResource = resource(...);

  removeEntity = signal<Entity | undefined>(undefined, {
    equals: false, // enable to set same entity again
  });

  removeEntityDialogRef = rxResource({
    params: this.removeEntity,
    stream: ({ params: entity }) =>
      this.dialog()
        .open(RemoveEntityModalComponent, {
          data: { entity },
        })
        .afterClosed(),
  });

  removeEntity = rxResource({
    params: () =>
      this.removeEntityDialogRef.value()
        ? this.removeEntity.value()
        : undefined,
    stream: ({ params: entity }) => this.entityService.deleteEntity$(entity.id),
  });

  remove(entity: Entity) {
    this.removeEntity.set(entity);
  }

  _onRemovedEntity = effect(() => {
    if (this.removeEntity.status() === 'resolved') {
      this.entitiesResource.update((entities) =>
        entities?.filter((e) => e.id !== entity.id)
      );
    }
    if (this.removeEntity.status() === 'error') {
      this.entitiesResource.reload();
    }
  });
Enter fullscreen mode

Exit fullscreen mode

It almost addresses all the weaknesses mentioned earlier.

Since the dialog is based on an observable, we don’t need to refactor that part of the code yet (it can be quite complex). Instead, we can simply bind it to an rxResource.

Otherwise, this example doesn’t really need to use RxJS anymore, and it’s relatively easy to switch to Signals.

In the next part, I’ll show how the dialog API can be adapted to implement events based on Signals.




Implementing Advanced Patterns Effortlessly

Moreover, this new pattern allows us to implement parallel entity deletion, which wasn’t really possible (or easily controllable) before.

Thanks to the declarative nature of the code, enabling this behavior only requires using resourceByGroup, which allows parallel requests.

It’s not an official resource — I created it myself.

Here’s a LinkedIn post presenting it, along with the implementation.

  removeEntityByGroup = rxResourceByGroup({
    params: () =>
      this.removeEntityDialogRef.value()
        ? this.removeEntity.value()
        : undefined,
    identifier: (params) => params.id,
    stream: ({ params: entity }) => this.entityService.deleteEntity$(entity.id),
  });
Enter fullscreen mode

Exit fullscreen mode

This is the same property exposed by resource, with an identifier used to create a key that indexes a mapper object.

You can access the dedicated resource like this:

removeEntityByGroup[entity.id]?.status() === 'loading'




Going Further — Making the Code Fully Declarative with One of My Tools

There’s still an effect used to update the entitiesResource to keep the resource value in sync without reloading it.

Personally, I consider this a bad practice, but I understand it’s more challenging to implement a fully declarative resource — so it might be acceptable in this case.

As you may know, entitiesResource represents server state (it should reflect what’s stored in the backend).

Using client-side state management tools like NgRx (global or Signal Store) or NGXS doesn’t really improve the developer experience compared to what we’ve already achieved.

This is where your project should rely on a proper server state management tool.

For example:

  • TanStack Query for Angular, or
  • my latest tool: ng-query, which is built on signalStore and pure Signals (RxJS is optional, but still experimental).

With TanStack Query, you’d need to create a wrapper that binds the targeted entity to a mutation — which can be a bit tricky if you’re not used to it.

ng-query is more suitable, but I’m currently working on an even simpler solution that I’d like to show you.

It doesn’t have a name yet, but here’s an implementation of the example that fully addresses all the points mentioned earlier.

A proper tool to handle server state

Tell me if you like the idea.



Conclusion (Part 1)

When working with Signals, remember:

  1. Your code should follow declarative patterns.
  2. Signals promote a state-driven architecture.
  3. Track all asynchronous processes, just like Resources do.
  4. The undefined keyword is meaningful — use it intentionally.
  5. Don’t expose your Effect logic directly.

Find clean, reusable patterns that you can apply easily and consistently.

I’ll also share some useful patterns for handling server state declaratively — without needing any external tools.

(But first, I’ll finish publishing this series of articles.)

Try to think Signal-first. RxJS will become increasingly optional, and most RxJS code can be transformed into Signals.

As you adopt more Signal-based patterns, you’ll eventually need to implement an event-like system.

That’s the topic of the next article!

If you’re interested in how to do that and don’t want to wait, send me a message on LinkedIn. I’ll share some snippets with you.


If you don’t know me, I’m Romain Geffrault, and I regularly share Angular/TypeScript/RxJs/Signal content.
Check out my other articles and follow me on LinkedIn Romain Geffrault



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *