DEV Community

Cover image for Signals: the Do-s and the Don't-s
Armen Vardanyan for This is Angular

Posted on

Signals: the Do-s and the Don't-s

Original cover photo by Shyam on Unsplash.

If we ask any Angular developer "What is the hottest topic in Angular right now?", with almost 100% certainty everyone would respond "Signals!". And they would be right. Signals are the hottest topic in Angular right now.

Of course, there is now a multitude of articles, videos, blog posts and so on, describing in deep detail what signals are, how they work, and different use cases, so we can safely assume almost everyone in the Angular community now knows about signals, and maybe at least has tried them out. But a fact that became quite obvious recently is that the community needs a specific set of rules for working with them, a set of Do-s and Don't-s, if you will. In today's article, let's dive into the world of signals, and find out examples of use cases where using signals in a specific way is not a good idea, and see what we should do instead. Let's get started!

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

When signals first dropped in v16, support for lots of features was limited (as signals themselves were experimental). For instance, it was impossible to use signals as inputs to components. So, the community came up with a workaround: using setters to turn inputs into signals. The idea is simple: we create a setter for an input and a separate signal, and inside the setter we set that signal's value to be the value of the input. Then, we can use the signal as an input to 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

Obviously, this is quite verbose, especially if we have lots of inputs. This is really a lot of boilerplate, so let's see what we can do about it.

Do use signal inputs

Starting from Angular v17.1, a new way of declaring input properties for components and directives has emerged: the input function. This function declares an input, but its value is a signal, rather than a plain property. We can simplify our example now:

@Component({
  selector: 'app-my-component',
  template: `
    <div>
      {{ inputSignal() }}
    </div>
  `
})
export class SomeComponent {
    inputSignal = input<string>('default value');
}
Enter fullscreen mode Exit fullscreen mode

Note: input signals are not stable as of v17.1, but will move to stable soon.

Of course, this is much, much better. This also supports all the features we have with regular inputs, for instance, to create a required input, we can do this:

export class SomeComponent {
  inputSignal = input.required<string>(); // we do not need to provide a default value here
}
Enter fullscreen mode Exit fullscreen mode

We can also use transformers:

export class SomeComponent {
  booleanSignal = input(true, {transform: booleanAttribute});
}
Enter fullscreen mode Exit fullscreen mode

And aliases:

export class SomeComponent {
  inputSignal = input.required({alias: 'condition'});
}
Enter fullscreen mode Exit fullscreen mode

Warning: signal inputs are read-only, it is not possible to set another value to it in the child component

Of course, the previous approach was a workaround for a missing feature, and with time all Angular developers will move to signal inputs. Now, let's focus on some more serious stuff, that can happen in any codebase.

Don't retrieve data via HTTP calls in effects

This is a common mistake that can happen in any codebase. Let's say we have an input in our template, and we want to make an HTTP call when the input changes. We can do this:

@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

However, this poses several problems. First of all, the items collection is not a signal (we can make it into a signal, but then we would have to set {allowSignalWrites: true} on the effect, which is an even worse practice), which will make it hard to work with zoneless change detection in the future. Next, the declaration of items is separate from the place where its value is actually set, making it a bit harder to understand how it works; and finally, the triggering of the effect is decoupled from RxJS, meaning we cannot really harness the power of timing operators (like debounceTime in order to prevent unnecessary HTTP calls), as each change to the query signal will create a separate Observable.

Do use toSignal + toObservable

However, there is an easy way to circumvent all of those problems by utilizing the interoperability between signals and RxJS. Let's make a cleaner and more powerful way of doing this:

@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 move to the RxJS land with toObservable, make our asynchronous stuff there, and then move back to signals with toSignal. This way, we can use all the power of RxJS, and still have a signal in the end, and additionally, we solve the timing issue of multiple requests being made when the user types fast.

Let's move towards a more obscure example.

Don't forget about untracked

Let's imagine we have a CompanyDetailsComponent page that displays company data, and a list of its employees, and allows general editing, for instance, we can change the description text of the company. There can be a lot of employees, so we want to be able to search through them with a query, as in the previous example. However, we are not searching through the entirety of employees, but only through the ones that are employed at this particular company, meaning that every time we search for an employee, we have to use the company ID too. We can do this:

@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

We can see that this example is somewhat complex. Let's break it down to simplify:

  • The component receives the list of all employees and the company details
  • We use a computed signal to get the list of employees working at this particular company and match the query
  • We can update the company description, and emit an event when it happens so that the parent makes necessary HTTP requests
  • We use the computed signal in the template

This will work just fine, however, we overlooked something: when the description gets updated, it is sent to the parent component, which will then send a new reference to the company signal input, and because it is also used in the computed companyEmployees signal, this will trigger the computation unnecessarily, as we know the id cannot change, and we don't really care about the description, or any other property for that matter.

This can become even worse if we use an effect to trigger an HTTP call to make the search through employees, resulting in a lot of unnecessary HTTP calls every time the user types something in the unrelated description input.

Do use untracked

Thankfully, this issue has a very simple solution: using the untracked function to read the signal's value instead of calling it the usual way. untracked will return the current value of the signal without marking it as a dependency of the current computed signal/effect, meaning that the computation will not be triggered when the value of that signal changes. Here we go:

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 with signals being called in the computed or effect function callbacks, rigorously check if they really need to be a dependency, and use untracked if they don't.

Problem solved! Now, let's move to a really complex example, one that most people will overlook unless they are explicitly aware of this issue.

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

Lots of Angular applications do not use dedicated state management libraries like NgRx or Akita, and instead use services to manage the state of the application. This is a perfectly valid approach and can work really well. Most of such services use RxJS Observable-s (more specifically Subject-s or BehaviorSubject-s) to propagate changes to the state throughout the application. Of course, now, it might be tempting to use toSignal to expose the Observable as a signal, so that the rest of the application can consume it directly. However, this can lead to a bunch of problems. One glaring one is that the signal will not be automatically disposed of when the component which uses it is destroyed, and the Observable will keep emitting values, which might not be the desired outcome. Also, Observable-s in general are way more versatile, and some components might choose to handle such values in a slightly different way, which will not be possible if the value is exposed as a signal. Let's view 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 scenario, we are reading messages in a live stream from some external service (FireBase, WebSocket, does not matter), and the source will connect to the service when the first subscriber appears, and disconnect when the last subscriber is gone. This is a perfect use case for an Observable, but not really for a signal. While we got some benefits like using computed, we also made sure that the connection starts as soon as the service is created, and does not really ever stop, even when the components that consume those signals are destroyed, as it is the MessagesStore that is the subscriber, and it will not be destroyed until the application is closed.

Do use toSignal in components or expose state via connecting methods

So, what can we do about this problem? Well, one straightforward way of achieving this can be just not exposing signals from services at all and only call toSignal in components that consume the state. This way, we can be sure that the connection to the source will be established only when the component is created, and will be destroyed when all the consumer components are destroyed. This is a normal approach for most apps, however, it can be a bit tedious, as we might end up copy-pasting computed signals here and there (for instance, unreadMessages in the previous example).

So, instead we can opt for using specific methods that "connect" a component to the Observable, while still returning a signal that lives in the component's injection context, and not the service's. Let's see how we can modify the previous example to use this approach:

@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

This way, we use the messages method to actually get the overall messages, and in the case of other computed properties, we can use other methods that return computed properties. In the component, we can simply do 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 way, we can ensure the stream we subscribe to in the component is safely disposed of when the component is gone. Of course, there are other ways of achieving this, but we list the simplest ones here. You can try and find other ways that best suit your project and codebase, but remember not to use the toSignal function in a shared service.

Conclusion

This article is meant to be a small ruleset for Angular developers when they deal with signals. Signals truly are exciting, and lots of teams now increasingly adopt them in their projects, but they can have caveats, and it is important to be careful about them. If you know more such issues, feel free to comment - let's see how we can solve them together!

Small promotion

As you might already know, I was very busy writing a book last year. It is called "Modern Angular" and it is a comprehensive guide to all the new amazing features we got in recent versions (v14-v17), including standalone, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If this interested you, you can find it here. The manuscript is already entirely finished, with some polishing left to do, so it is currently in Early Access, with the first 5 chapters already published, and more to drop soon. If you want to keep yourself updated on the progress, you can follow me on Twitter or LinkedIn, where I will be posting whenever there are new chapters or promotions available.

Top comments (10)

Collapse
 
spock123 profile image
Lars Rye Jeppesen

Very informative, I have been guilty of using "allowSignalWrites" in effects, at least I updated signals :) .. but I love the patterns you showed here.

As a side note, I just ported one of our internal applications to use signal inputs(), and it's so awesome. The best thing? ngOnChanges are now no more needed, effect() and computed() to the rescue. Awesome.

NgOnChanges is really horrible to work with

Collapse
 
armandotrue profile image
Armen Vardanyan

Yes, signal inputs are clearly a superior approach! Totally agree. Thanks for appreciating the article :)

Collapse
 
tmish profile image
Timur Mishagin

Good article. Thanks.
Have recently upgraded to the latest Angular. Very excited to start working with Signal inputs!

Collapse
 
armandotrue profile image
Armen Vardanyan

Great to hear I have been of help to you!

Collapse
 
mezieb profile image
Okoro chimezie bright

Thank you for sharing.

Collapse
 
ricardogesteves profile image
Ricardo Esteves

Thanks for sharing!

Collapse
 
danishhafeez profile image
Danish Hafeez

Informative Post well done keep it up

Regard
Danish Hafeez | QA Assistant
ictinnovations.com

Collapse
 
armandotrue profile image
Armen Vardanyan

Thanks for your appreciation!

Collapse
 
jangelodev profile image
João Angelo

Hi Armen Vardanyan,
Excellent content, very useful.
Thanks for sharing.

Collapse
 
fore profile image
Fore

Thank you for sharing, Armen! Really useful.

Question about companyEmployees. How it will get updated if a company value is updated with selector for example and it is untracked?