Angular is a very opinionated framework, with multiple different rules and solidified approaches that help developers easily navigate decisions within their projects.

However, as with any tool, there is some variability, and the core team cannot, and, more importantly, will not make every single decision in developer's stead. In some situations, two or more tools or approaches are available, and it is up to the developer to choose the one that best fits their needs.

This can be done either via subjective opinions ("I like using different templates", or "I don't want RxJS"), via the "consistency" argument (this codebase already uses Reactive forms, we should always stick to them), or on a case-by-case basis ("here it makes sense to use ngModel, but here a Reactive form is more appropriate because of the validations").

These situations often cause heated debates online, and there is no one definitive answer to all the dilemmas. We will soon discover that more often than not, adherence to a specific rule is the key to a highly maintainable codebase, rather than some sort of "higher truth" which compels us to always use the same approach.

So, in this article, we will discuss different friction points that cause these debates, dive into both the upsides and downsides of both approaches, and provide some guidance on how to choose the right tool for the job. Notice that those sections will be titled "How to choose?" rather than "What to choose?", because we are going to provide advice to help you decide, rather than decide in your place.

Let's begin the discussion with some of the simple issues and move on to more complex ones.

NgClass vs class attribute

In Angular templates, we can use either the ngClass directive or the class attribute to conditionally apply CSS classes to an element. Both have their merits.

NgClass

Upsides:

While both [class] and NgClass can be used to apply multiple classes to an element:

<div [ngClass]="{'active': isActive, 'disabled': isDisabled}">
    ...
</div>

NgClass also supports keys with spaces:

<div [ngClass]="{'one two': isActive}">
    ...
</div>

While the same will not work with [class] and neither of the classes will be applied.

Downsides:

NgClass can be more verbose than the class attribute if we use it to apply a single dynamic class. Also, it is a directive, which must be imported into the component. In addition to this, NgClass also supports binding to a JavaScript Set of string to represent classes, which, albeit rare, can prove useful in some situations.

Class attribute

Upsides:

class attribute is a native HTML attribute, so in this case, we will be using Angular's binding mechanism without the need to import a directive. It can also be less verbose when applying a single class. Take a look at this:

<div [class.disabled]="isDisabled">
    ...
</div>

And then compare it to this:

<div [ngClass]="{'disabled': isDisabled}">
    ...
</div>

As we can see, class binding is a bit shorter.

Downsides:

class attribute can be either bound to a single class, or be bound to an object with boolean values, with keys that do not contain spaces. It cannot be used with other formats, in contrast to ngClass.

How to choose?

In this scenario, it is obvious that only two realistic ways of thinking exist:

  1. Choose one and consistently apply it to all cases
  2. Use class binding for simple cases and ngClass for complex ones or ones involving data structures like Set

Best course of action will be to pick one approach that you are most comfortable with, and stick with it. Now, onto our next simple case.

"inject" function vs Constructor DI

Since Angular v14, we have been able to use the inject function to inject dependencies into our components instead of relying on the constructor. Lots of developers already abandoned constructor DI, and Angular has switched lots of its building blocks (like interceptors and guards) to a functional approach with "inject". However, the discussion is still somewhat alive, with some developers unwilling to make the switch. Let us figure out how to make a decision.

"inject" function

Upsides:

The inject function is less verbose, does not require a constructor function at all, and can be used everywhere (within injection contexts, so it must be called from a constructor or DI factory function), not just classes. It also makes it simpler to apply DI lookup options. Consider this example:

@Component({...})
export class MyComponent {
    constructor(
        @Optional() @SkipSelf() private readonly someService: SomeService,
    ) {}
}

And compare it to this:

@Component({...})
export class MyComponent {
    private readonly someService = inject(SomeService, {optional: true, skipSelf: true});
}

The inject function also makes it easier to deal with typings ofInjectionToken-s. Here we can see the difference:

export class MyService {
    constructor(
        @Inject(SOME_TOKEN) private readonly token: SomeTokenType,
    ) {}
}

VS

export class MyService {
    private readonly token = inject(SOME_TOKEN); // this will implicitly have the type SomeTokenType
}

It is important to note that the previous example (with the @Inject decorator) relies on an experimental TypeScript feature called experimentalDecorators, which might get dropped in the future as ECMAScript specification for the future decorators implementation is different from what we have in TypeScript right now (decorators on constructor arguments are not supported). Read more about this here. This fact makes it more preferable to just use inject.

Downsides:

inject function can sometimes cause confusion when used outside injection contexts. We can call it within any function, but that function ultimately has to be called from a component or service constructor. It is important to note that we cannot classify this as a limitation, because constructor DI, requires, well, a constructor anyway, so, in fact, we can run into issues because we actually expanded the capabilities of dependency injection. The error that we might encounter is easy to debug and fix.

A more real downside of this approach is unit testing services. With constructor DI, we can easily created an instance of a service to test, and provide mock values as its dependencies via the constructor. Look at this:

describe('SomeService' () => {
    let instance: SomeService;
    beforeEach(() => {
        instance = new SomeService(otherServiceMock);
    });
});

We can easily do this with classes that use constructor DI. However, with the inject function, we have to run the tests within the injection context, which is quite verbose:

describe('SomeService' () => {
    let instance: SomeService;
    beforeEach(() => {
        TestBed.configureTestingModule({
            providers: [
                {provide: OtherService, useValue: otherServiceMock},
            ],
        });
        TestBed.runInInjectionContext(() => {
            instance = inject(SomeService);
        });
    });
});

This is, of course, a visible downgrade. However, we must note two important things here.

  1. This only applies to services in unit tests. If you don't write unit tests, you won't encounter this issue. If you unit tests components and directives, which have to be initialized with TestBed, you will not run into this even if they use the inject function.
  2. Using TestBed when testing all Angular building blocks is the recommended and preferred way, as TestBed mimics the behavior of the framework as close as possible.

Constructor DI

Upsides:

Constructor DI, as we have seen, is better for unit testing services. It is also a bit more familiar to developers that are used to dependency injection from other frameworks and languages, like .NET and such. Unfortunately though, this is where the upsides of this approach end.

Downsides:

As obvious from the examples, constructor DI is not as flexible as the inject function, it needs a constructor, and is not very good for typings of InjectionToken-s.

How to choose?

Here we, again, have two options:

  1. Just adopt and stick to the inject function. The only downside we have seen is not that big of a deal to drop this approach
  2. Use inject everywhere other than services for simpler unit testing services. Can be seen as a valid approach, but can also complicated things.

"Stick to constructor DI" is no longer an option, because as mentioned, several building blocks like guards and resolvers have been switched to functional approaches with inject, and their class counterparts have been deprecated. It would seem that the Angular team itself favors the inject function, and will provide a migration schematic in v19 to seamlessly adopt the new approach.

Now, let's move on to a really controversial one.

Single file components vs separate templates

As we know, Angular components allows us to either provide an inline string as a component template, or use a separate HTML file. Since its inception, this has been one of the hot questions in the framework, so let us quickly decouple the pros and cons.

Separate templates

Upsides:

The HTML code is stored separately from the component code, and looks less like magic. Some IDEs might have better tooling for this approach rather than inline templates (worth noting that the Angular Language Service usually mitigates this problem).

Downsides:

Separate templates can give developers a false sense of security, as they might think they are separating concerns all the while adding more and more code to the templates. They can also be harder to search through, as IDEs can sometimes lag when switching between files.

Note: If you want to use separate templates, feel free to explore this extension which adds hot keys that allow to quickly navigate between a component's template, styles and tests.

Single file components

Upsides:

With SFCs, we will have way less files and folders in the application structure. SFCs can also be easier to navigate, as everything related to a component is also in the same file.

Downsides:

SFCs can be seen by some as a violation of the Single Responsibility Principle, as they combine 3 different languages (HTML, TypeScript, CSS) into one file. This is of course debatable, because SFC advocates will say that "concerns" here relates to actual business concerns like features and pages, and not the underlying technology that is used. At the end of the day, HTML, TS and CSS files of the same component are usually edited together.

How to choose?

This is a very subjective question, and it depends on the team and personal preferences. However, we can establish ground rules for both approaches to make them work:

  1. Choose SFCs and limit the length of a single component file. This is easier to achieve in this case, as we only have one file to keep track of, and it will compel us to keep our components short and concise.
  2. Choose separate templates and using static code analyzer tools to enforce file length. In this case, the main concern is that separately, TS, CSS and HTML files will be short, but together, as a unit, they might contain way too much logic and actually inadvertently violate the Single Responsibility Principle. This can be mitigated by adopting a policy of keeping all files short.

Now, let's move to a friction point that is so subjective, we can surely say it does not have a definitive answer.

Template-driven forms vs Reactive forms

In Angular, as we now, we have two ways of handling forms, using ngModel or Reactive Forms with FormControl-s. Let's take a look at the pros and cons of each approach, but keep in mind that this one often really comes down to personal preferences.

Template-driven forms

Upsides:

Template-driven forms are easier to use and get familiar with, as we can use the ngModel directive to bind the form to any property of the component. This has become even significantly easier with the introduction of signals:

@Component({
    template: `
        <input type="text" [(ngModel)]="text">
        {{ text() }}
    `,
    imports: [FormsModule],
})
export class MyComponent {
    text = input<string>();
}

As we can see, all we need to do is to import the FormsModule and drop the ngModel directive somewhere. We than can use the signal (or any other property) to perform any actual business logic that we have. In this scenario, the template itself is used to describe what is going on with our form (hence template-driven forms).

Downsides:

In the case of signals, it can be hard to extract the value of an object that represents our form. Consider this:

@Component({
    template: `
        <input type="text" [(ngModel)]="form.email">
        <input type="password" [(ngModel)]="form.password">
    `,
    imports: [FormsModule],
})
export class MyComponent {
    form = {
        email: signal(''),
        password: signal(''),
    };

    submitForm() {
        // this can become verbose if we have a lot of fields
        const formValue = {
            email: this.form.email(),
            password: this.form.password(),
        };
        // submit the form
    }
}

In the case of Reactive Forms, we can just use the form.value property to get the value of the form.

Another downside is the validations. With template-driven forms, we use native HTML validations, and for custom validations, we have to use directives, which increases the amount of code we need to write.

@Directive({
  selector: '[appPositiveNumber]',
  providers: [{provide: NG_VALIDATORS, useExisting: PositiveNumbersValidatorDirective, multi: true}],
})
export class PositiveNumberValidatorDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    return (+control.value) > 0 ? null : {positiveNumber: true};
  }
}

@Component({
    template: `
        <input type="number" appPositiveNumber [(ngModel)]="number">
    `,
    imports: [FormsModule, PositiveNumberValidatorDirective],
})
export class MyComponent {
    number = signal(0);
}

But with a reactive form, the validator will be a simple function:

function positiveNumberValidator(control: AbstractControl): ValidationErrors | null {
    return (+control.value) > 0 ? null : {positiveNumber: true};
}

@Component({
    template: `
        <input type="number" [formControl]="number">
    `,
    imports: [ReactiveFormsModule],
})
export class MyComponent {
    number = new FormControl('', {validators: [positiveNumberValidator]});
}

Reactive forms

Upsides:

Reactive forms are clearly defined as forms, with everything that we need to know about them written in a single precise location. including validators, initial value, disabled state, and much more. In the template, we only need to bind the controls to specific inputs, and the rest is handled by the framework.

Another upside is that we can easily use the form.value property to get the value of the form. We can use the built-in valueChanges and statusChanges observables to listen to changes in the form (this is not an issue when using signals for template driven forms), and much more.

Finally, a more "abstract" upside of embracing RxJS is making sure that developers write more declarative code, instead of running around a component trying to keep different parts of the state in sync. This can be especially useful when adopting state management solutions like NgRx.

Downsides:

Reactive forms are obviously more verbose, and while they are allow us to clearly define a for with everything that is needed for it to function, it is still a wrapper around a value, which means we might have to perform extra actions to keep the value in sync with the form itself. For instance, if we want to add a control depending on another controls value, it can be relatively easy with template-driven forms that contain signals. Take a look at this example, where choosing a certain topic adds an option to choose a subtopic, done with a template-driven form:

@Component({
  template: `
    <form>
      <div>
        <label for="title">Title</label>
        <input type="text" id="title" name="title" [(ngModel)]="form().title" />
      </div>
      <div>
        <label for="topic">Topic</label>
        <select id="topic" name="topic" [(ngModel)]="form().topic">
          @for (topic of topics(); track topic.id) {
            <option [value]="topic.id">{{ topic.name }}</option>
          }
        </select>
      </div>
      @if (form().subtopic) {
        <div>
            <label for="subtopic">Sub Topic</label>
            <select id="subtopic" name="subtopic" [(ngModel)]="form().subtopic">
            @for (subTopic of subTopics(); track subTopic.id) {
            <option [value]="subTopic.id">{{ subTopic.name }}</option>
            }
            </select>
        </div>
      }
    </form>
  `,
})
export class AddQuestionComponent {
  private readonly topicService = inject(TopicService);
  defaultControls = {
    title: signal(''),
    topic: signal<number | null>(null),
  };

  hasSubtopic = computed(() => {
    return !!this.topics().find(
      (topic) => topic.id === +(this.defaultControls.topic() ?? 0)
    )?.hasSubtopic;
  });

  form = computed(() => {
    return ({
      title: this.defaultControls.title,
      topic: this.defaultControls.topic,
      ...(this.hasSubtopic() ? { subtopic: signal<number | null>(null) } : {}),
    });
  });

  topics = toSignal(this.topicService.getTopics(), { initialValue: [] });
  subTopics = toSignal(
    toObservable(this.defaultControls.topic).pipe(
      filter((topicId) => this.hasSubtopic()),
      switchMap((topicId) => this.topicService.getSubTopics(topicId!))
    ),
    { initialValue: [] }
  );
}

Here, we can define the form with computed value, and account for a specific topic that might have subtopic options. With a reactive form, however, the logic will be much more complicated:

@Component({...})
export class AddQuestionComponent implements OnInit {
  private readonly topicService = inject(TopicService);
  form = new FormGroup({
    title: new FormControl(''),
    topic: new FormControl<number | null>(null),
  });

  ngOnInit() {
    // here, we need to separately subscribe to form changes
    // and add/remove the control manually
    this.form.get('topic')?.valueChanges.subscribe((topicId) => {
      if (
        topicId && 
        this.topics.find((topic) => topic.id === topicId)?.hasSubtopic
      ) {
        this.form.addControl('subtopic', new FormControl(null));
      } else {
        this.form.removeControl('subtopic');
      }
    });
  }

  topics = toSignal(this.topicService.getTopics(), { initialValue: [] });
  subTopics = toSignal(
    toObservable(this.form.get('topic')?.value).pipe(
      filter((topicId) => this.form.get('subtopic')?.value),
      switchMap((topicId) => this.topicService.getSubTopics(topicId!))
    ),
    { initialValue: [] }
  );
}

This looks way more complicated than what we have with template-driven forms, and can become more and more complex as the form receives more input controls.

As we can also see, the API for Reactive forms is very imperative. To disable an input, we need to call disable() on the control, and to enable it, we need to call enable(). We cannot just bind to a property in the template like we can do with template-driven forms, which might result in hacky code. Often we might need to toggle a disabled state based on an Observable value, with template-driven forms we can just bind it to the disabled attribute using the async pipe, but with reactive forms, we have to subscribe to it and use the disabled property of the control, which can make our code quite ugly, especially if we have lots of scenarios like this.

How to choose?

Now, this one often really does come down to personal preference. However, this is the case where consistency in rules is key. We have several equally valid options:

  1. Choose reactive forms and stick to it.
  2. Choose template-driven forms and stick to it.
  3. Use template-driven forms for simple cases with defined rules and boundaries (for instance, if we have no validations, let's just use a template-driven form in that case), and reserve reactive forms for far more complex scenarios with multiple inputs and cross-cutting validations.

Now, let's move on to the final topic of the day, one that might actually turn out not to be a dilemma at all!

RxJS vs Signals

RxJS has been a part of Angular since its very beginning, while signals are relatively new. But this already resulted in a big debate online; RxJS was divisive for Angular developers even before signals, but now even more users are eager to ditch it. But is this warranted, or actually a hasty move?

RxJS

Upsides:

RxJS is a very powerful library, and it is used in many places in the framework. We already touched it with Reactive Forms, for instance, and, of course, it is used in many other core framework features, like the HttpClient and Router.

RxJS has many operators, different approaches for different tasks, and can be very, very flexible. When done correctly, it can produce beautiful, easily readable and maintainable code. It can be both synchronous and asynchronous. Really, our imagination might be the only limit here.

Downsides:

While RxJS is extremely powerful, it is also intimidating. It has more than 100 operators, different caveats like cold and hot Observables, and potential issues when mishandled like memory leaks. Also, because it is an independent library, there are many multiple ways of handling the same thing, and differing opinions on usage, like "should you subscribe or use the async pipe?" and so on.

In addition to what we said, it is worth mentioning that RxJS is not a silver bullet, and can unnecessarily complicated synchronous code. This is where signals come in to shine.

Signals

Upsides:

Signals are very simple, its easy to create one, derive from another, compose them, and react to their changes via effect-s. They are also way more predictable than Observables, which makes it easy to reason about them. Their potential for abuse is lower than RxJS.

Downsides:

Signals are synchronous, which is both an upside and a downside. Of course, synchronous things are way easier to deal with - we can read their current value whenever we want, and there are no race conditions. However, we still need asynchronous functionality in our applications (HTTP calls say hello), and signals are not really suited for those things (by design).

Signals also lack the versatility of operators that allow to transform, time, filter and combine them in more than one way. All of that responsibility is delegated to us, the developers.

So, what gives?

How to choose?

This section will be significantly longer than the others.

First of all, we need to understand if there is actually any animosity between signals and RxJS. If we think about it for a while, we will realize that there is none. Signals are well-suited to contain reactive values - values that can be read and changed at any moments, but whose changes can also be tracked to make our application to react to them. RxJS is great for dealing with events (browser events, HTTP calls, storage events, Web Sockets and much more) in a way that allows us to control both the nature of the emitted items (filter, map, combine them) and the timing (debounce, timer, interval and so on).

This makes it clear that they are certainly meant for somewhat different purposes, but their goals often intermingle. For instance, HTTP calls are asynchronous events, so RxJS is good, but the value that they return is a reactive data, so, signals might be great to store, use and react to them. This means it would be amazing if both existed within Angular apps and could easily "talk" to each other.

So... that is exactly what the Angular team did. While slightly decreasing the dependency on RxJS, they also introduced the "@angular/core/rxjs-interop" package, which allows us to easily translate between signals and Observables to produce both simple and powerful code.

Let's see a glimpse of that power in action:

@Component({
    template: `
        <input type="text" [(ngModel)]="query">
        <ul>
            @for(product of products(); track product.id) {
                <li>
                    {{ product.name }} - {{ product.price }}
                </li>
            }
        </ul>
    `,
    imports: [
        FormsModule,
    ],
})
export class ProductListComponent {
    private readonly productService = inject(ProductService);
    query = signal('');
    // extract the result of the HTTP call into a signal
    products = toSignal(
        // switch to Observable world 
        toObservable(this.query).pipe(
            // use the power of RxJS operators to introduce timing limits
            debounceTime(500),
            switchMap(query => this.productService.getProducts(query)),
        ),
        { initialValue: [] },
    )
}

Here, as we can see, the combination of RxJS and signals allowed us to create a powerful abstraction over making a timed HTTP request, and in the end just expose the result as a signal to be used both within the template and in other parts of the component.

With such advances, is it reasonable to pit signals and RxJS against each other? My own opinion would be no. Here is how we can approach the decision making process:

  1. Simply use both RxJS and signals in your application. This is the most straightforward approach, but will require a learning curve.
  2. Use RxJS exclusively. This suits for applications that are on older versions of Angular that do not yet have signals - sadly, a very huge chunk of existing apps. However, using Observables will make it easier to also adopt signals later on.
  3. Use signals exclusively. If you really don't want to use RxJS, it is acceptable, and the framework is in general moving towards the direction of making RxJS optional (but better supported!). Also this works for really simple apps.

I purposefully did not include the "don't use either", because usually not utilizing either of these tools is more of a situation, rather than a choice; an old project, which developers already created without RxJS.

Whichever you use, keep in mind that RxJS will most probably never go away, and, as mentioned will most likely become even better synchronized with the framework.

Conclusion

Angular, as any other tools, is driven and used by humans, who, often, have very, very different opinions. This disagreements are inevitable, but should never be a deal-breaker for the tool, and should never hold us back from trying new things out, or changing our mind. If you are just starting your Angular journey, this discussions might sometimes feel quite overwhelming (what do I do?!), so, hopefully, this article gives you the tools to try and make your own, informed decisions.

Small promotion

As you see, some of the features that we discussed, like inject and signals, are quite new to Angular. The recent upheaval in Angular has caused many developers to be confused about what solutions to chose, how to implement them, and how to migrate their existing codebases to the most recent features. Thankfully, I have a response to this concern: very soon, my very first book is going into print!

It is called "Modern Angular" and it is a comprehensive guide to all the new amazing features we got in recent versions (v14-v18), including standalone, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If this interested you, you can find it here.

The book is now in the copy-editing phase with a release scheduled shortly, so it is currently in Early Access, with all the 10 chapters already available online. If you want to keep yourself updated on the print release, you can follow me on Twitter or LinkedIn, where I will be posting whenever there are news or promotions available.

P.S. Hey! Check out chapter 5 of my book to learn more about RxJS and Angular interoperability, and chapters 6-7 to dive deep into signals ;)


Last Update: October 17, 2024