Original cover photo by Shyam on Unsplash. If you ask virtually any Angular developer what the current hot topic in the framework is, chances are the answer will be “Signals!” — and rightly so. Signals are without a doubt the most talked-about feature in Angular at the moment. There is already a wealth of resources — articles, videos, blog posts — explaining what signals are, how they function, and where they can be applied. At this point, most of the community has at least experimented with them. However, what has become increasingly apparent is that developers need a clear set of guidelines and anti-patterns to avoid common pitfalls. In this piece, we’ll explore various scenarios where a specific use of signals might not be ideal, and we’ll discuss what alternatives exist. Let’s jump right in.

Don't use setters for inputs to turn them into signals

When signals first appeared in v16, their experimental status meant that certain features weren’t fully supported. For example, signals could not yet serve as component inputs. This limitation led the community to devise a workaround: using a setter to convert an input into a signal. The concept is straightforward — define a setter for the input, create a signal, and inside the setter, assign the input’s value to that signal. This then allows the signal to be used as the input elsewhere in the component.
@Component({
  selector: 'app-my-component',
  template: `
    <div>
      {{ inputSignal() }}
    </div>
  `
})
export class SomeComponent {
  inputSignal = signal<string>();

  @Input() set input(value: string) {
    this.inputSignal.set(value);
  }

}
Enter fullscreen mode Exit fullscreen mode
As you can see, this approach is rather verbose and produces a ton of boilerplate, especially when dealing with many inputs. Fortunately, there’s a cleaner path forward.

Do use signal inputs

In Angular v17.1, a fresh method for defining input properties in components and directives was introduced: the input function. This function creates an input whose value is a signal rather than a plain property. Here’s how our earlier example simplifies:
@Component({
  selector: 'app-my-component',
  template: `
    <div>
      {{ inputSignal() }}
    </div>
  `
})
export class SomeComponent {
    inputSignal = input<string>('default value');
}
Enter fullscreen mode Exit fullscreen mode
Note: as of v17.1, signal inputs are not yet stable, but they are scheduled to become stable soon.
This approach is substantially better. It also supports all the capabilities found in traditional inputs. For instance, defining a required input is straightforward:
export class SomeComponent {
  inputSignal = input.required<string>(); // we do not need to provide a default value here
}
Enter fullscreen mode Exit fullscreen mode
Transformers are also supported:
export class SomeComponent {
  booleanSignal = input(true, {transform: booleanAttribute});
}
Enter fullscreen mode Exit fullscreen mode
And so are aliases:
export class SomeComponent {
  inputSignal = input.required({alias: 'condition'});
}
Enter fullscreen mode Exit fullscreen mode
Warning: signal inputs are read-only. You cannot assign a different value to them within the child component.
The earlier setter workaround was simply a stopgap for a missing feature, and over time, the Angular community will migrate entirely to signal inputs. Now, let’s look at some more serious issues that can surface in any codebase.

Don't retrieve data via HTTP calls in effects

This is a mistake that can occur in almost any project. Suppose you have an input in your template, and you want to fire an HTTP request whenever that input changes. A tempting approach would be:
@Component({
  selector: 'app-my-component',
  template: `
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (item of items) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
}) 
export class SomeComponent {
  query = signal<string>();
  items: Item[] = [];

    constructor(private http: HttpClient) {
        effect(() => {
            this.http.get<Item[]>(`/api/items?q=${this.query()}`).subscribe(items => {
                this.items = items;
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
This pattern, however, introduces several complications. For one, the items collection is not a signal. You could convert it into one, but that would require setting {allowSignalWrites: true} on the effect — which is an even worse practice and makes things awkward for future zoneless change detection. Additionally, the declaration of items is isolated from the location where its value is actually assigned, which makes the code harder to follow. Finally, the effect’s trigger is completely detached from RxJS, which means you can’t leverage timing operators like debounceTime to prevent redundant requests. Every change to the query signal results in a separate Observable.

Do use toSignal + toObservable

There is, however, a simple way to bypass all these problems by leveraging the interoperability between signals and RxJS. Here’s a cleaner and more robust implementation of the same logic:
@Component({
  selector: 'app-my-component',
  template: `
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (item of items()) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
})
export class SomeComponent {
  http = inject(HttpClient);
  query = signal<string>();
  items = toSignal(
    toObservable(this.query).pipe(
      debounceTime(500),
      switchMap(query => this.http.get<Item[]>(`/api/items?q=${query}`))
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode
Here, we first transition into the RxJS world with toObservable, perform our asynchronous operations there, and then convert back to signals using toSignal. This not only grants us the full power of RxJS but also gives us a signal as the final output. In addition, it resolves the issue of excessive requests firing when the user types rapidly. Let’s move on to a trickier example.

Don't forget about untracked

Picture a CompanyDetailsComponent page that shows company information, a roster of employees, and allows general editing like changing the company description. The employee list could be long, so we want to enable search functionality, similar to the previous example. But here’s the twist: the search should be conducted only against employees belonging to this specific company. That means each search operation needs to include the company ID. Here’s a potential implementation:
@Component({
  selector: 'app-company-details',
  template: `
    <div>
      <h2>{{ company().title }}</h2>
      <input placeholder="Company description" 
      [ngModel]="company().description" 
      (ngModelChange)="updateCompanyDescription($event)" />
    </div>
    <input (input)="query.set($event.target.value)" />
    <ul>
      @for (employee of companyEmployees()) {
        <li>{{ item.name }}</li>
      }
    </ul>
  `
})
export class CompanyDetailsComponent {
  http = inject(HttpClient);
  query = signal<string>();
  company = input.required<Company>();
  employees = input.required<Employee>();
  companyEmployees = computed(() => {
    return this.employees().filter(employee => employee.companyId === this.company().id && employee.name.includes(this.query()));
  });
  @Output() companyUpdated = new EventEmitter<Company>();

  updateCompanyDescription(description: string) {
    this.companyUpdated.emit({
      ...this.company(),
      description
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
This example is a bit involved, so let’s break it down:
  • The component receives the full list of employees and the company details.
  • A computed signal filters employees to only those working at the current company and matching the search query.
  • The company description can be edited, and we emit an event upon changes so the parent can make any required HTTP requests.
  • The computed signal is used directly in the template.
On the surface, this seems fine, but we’ve missed something important: when the description is updated, it’s sent to the parent, which then passes back a new reference to the company signal input. As the company object is also referenced within the companyEmployees computed signal, changing it triggers a recomputation — unnecessarily so. We know the id is immutable, and the description or any other property doesn’t affect the employee filtering at all. This becomes even more problematic if we use an effect to trigger an HTTP request for searching employees. Each time the user types in the unrelated description field, we’d fire a superfluous API call.

Do use untracked

Thankfully, the solution here is rather simple: use the untracked function when reading the signal’s value instead of invoking it directly. untracked returns the signal’s current value without registering it as a dependency of the active computed signal or effect. In other words, its changes won’t trigger a recomputation. Let’s apply it:
companyEmployees = computed(() => {
  return this.employees().filter(employee => employee.companyId === untracked(this.company).id && employee.name.includes(this.query()));
});
Enter fullscreen mode Exit fullscreen mode
Note: in general, be careful when calling signals within computed or effect callbacks. Always double-check whether a signal truly needs to be a dependency, and use untracked when it doesn’t.
Problem resolved! Now, let’s examine a genuinely subtle case that many developers would miss unless they’re well-versed in this particular pitfall.

Don't use toSignal in services to expose Observables as signals.

Many Angular applications manage state through services rather than dedicated runtime libraries like NgRx or Akita. This is a perfectly valid pattern and can work quite effectively. The majority of these services rely on RxJS Observables — commonly Subjects or BehaviorSubjects — to broadcast state changes across the app. Now, the allure of toSignal might be strong: convert those Observables to signals so the rest of the application can consume them directly. While this seems convenient, it introduces a host of issues. One major problem is that the signal isn’t automatically cleaned up when the consuming component is destroyed; the underlying Observable keeps emitting, which may not be desirable. Moreover, Observables offer far more flexibility, and different components might prefer to handle these values in their own distinct way — something that’s not possible if the value is exposed as a signal. Let’s look at an example:
@Injectable({
  providedIn: 'root'
})
export class MessagesStore {
  private messagesService = inject(MessagesService);
  private messages$ = this.messagesService.getMessages();
  messages = toSignal(this.messages$);

  unreadMessages = computed(() => {
    return this.messages().filter(message => !message.read);
  });

  addMessage(message: Message) {
    this.messagesService.addMessage(message);
  }
}
Enter fullscreen mode Exit fullscreen mode
In this situation, we are consuming a live stream of messages from an external source (Firebase, WebSocket, etc.). The source connects when the first subscriber appears and disconnects when the final subscriber leaves. This is a textbook use case for an Observable — but not for a signal. While we gain some benefits, like the ability to use computed, we also inadvertently ensure that the connection is established the moment the service is instantiated and never really terminates, even if all components consuming those signals are destroyed. The reason is that the MessagesStore itself acts as the subscriber, and it lives as long as the application does.

Do use toSignal in components or expose state via connecting methods

So, how do we address this? There are a couple of viable strategies. The most straightforward is to avoid exposing signals from services altogether and only invoke toSignal in the components that actually consume the state. This ensures that connections to the source are initiated only when a component is created and torn down when all consuming components are gone. This approach suits most apps, though it can become somewhat repetitive, as you might end up duplicating computed signals (e.g., the unreadMessages from our earlier example). Alternatively, we can expose specific methods that “connect” a component to the Observable, while still returning a signal that operates within the component’s injection context rather than the service’s. Let’s see how we could refactor the previous example to adopt this pattern:
@Injectable({
  providedIn: 'root'
})
export class MessagesStore {
  private messagesService = inject(MessagesService);
  private messages$ = this.messagesService.getMessages();

  addMessage(message: Message) {
    this.messagesService.addMessage(message);
  }

  messages() {
    return toSignal(this.messages$);
  }

  unreadMessages(messages: Signal<Message[]>) {
    return computed(() => {
      return messages().filter(message => !message.read);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
In this design, we use the messages method to fetch the complete set of messages. For any derived computed properties, we can have additional methods that return those computed values. Inside the component, the usage would look like this:
@Component({
  selector: 'app-messages',
  template: `
    <ul>
      @for (message of unreadMessages(messages())) {
        <li>{{ message.text }}</li>
      }
    </ul>
  `
})
export class MessagesComponent {
  private readonly messages = inject(MessagesStore);
  messages = this.messagesStore.messages();
  unreadMessages = this.messagesStore.unreadMessages(this.messages);
}
Enter fullscreen mode Exit fullscreen mode
This pattern guarantees that the subscription created in the component is properly disposed of when the component is destroyed. There are certainly other approaches to achieve similar results, but these are some of the most direct. Feel free to experiment and find the solution that best fits your specific project, but remember: avoid using the toSignal function inside a shared service.

Conclusion

This article aims to provide a concise set of rules for Angular developers working with signals. Signals are an exciting feature, and more teams are integrating them into their projects every day. However, they come with their own set of caveats, and navigating them carefully is key to a successful implementation. If you’ve encountered other such tricky scenarios, feel free to share in the comments — let’s explore solutions together!

Book announcement

Those of you following my work may recall that I spent much of last year authoring a book. Titled "Modern Angular," it serves as an in-depth resource covering the latest features introduced in Angular v14 through v17 — standalone components, improved inputs, signals (naturally), enhanced RxJS interoperability, server-side rendering, and a host of other topics. If that sounds useful, you can grab a copy right here. The manuscript is fully written, though some final touches remain, and it is currently in Early Access with the first five chapters out and additional ones on the way. To stay in the loop about new releases and special offers, feel free to connect with me on Twitter or LinkedIn.