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);
}
}
Do use signal inputs
In Angular v17.1, a fresh method for defining input properties in components and directives was introduced: theinput 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');
}
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
}
export class SomeComponent {
booleanSignal = input(true, {transform: booleanAttribute});
}
export class SomeComponent {
inputSignal = input.required({alias: 'condition'});
}
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;
});
});
}
}
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}`))
),
);
}
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
});
}
}
- 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.
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()));
});
Note: in general, be careful when calling signals withinProblem resolved! Now, let’s examine a genuinely subtle case that many developers would miss unless they’re well-versed in this particular pitfall.computedoreffectcallbacks. Always double-check whether a signal truly needs to be a dependency, and useuntrackedwhen it doesn’t.
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);
}
}
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);
});
}
}
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);
}
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.
