#7: Loading every feature eagerly

Failing to take advantage of lazy loading ranks high on the list of Angular missteps, especially given that the feature is straightforward, natively supported, and delivers clear gains in both performance and bandwidth consumption.

The remedy is straightforward: segment your application into coherent modules that bundle related functionality, and then defer their loading until they are actually needed.

How to atone: Leverage the Angular Router's built-in lazy loading capability, or employ the dynamic import() function syntax.

#6: Organizing classes by their kind

A recurring pattern we encounter is the presence of directories named services, pipes, directives, or components at the root of an Angular project. At first glance, this seems logical—if you're hunting for a service, a dedicated folder sounds convenient. However, this approach introduces several complications:

  • These type-based folders quickly become catch-all containers for unrelated classes, making navigation a chore.
  • When a component and its dependent service are separated by several directory levels, you violate the Principle of Proximity, which advises placing files that change together near one another.
  • Scalability suffers: when all services, directives, pipes, and components are pooled into shared directories, refactoring becomes a much larger undertaking.

What should you do instead? Consider these guidelines:

  • Prioritize grouping by feature first, then by architectural layer, and only possibly by type at the end.
  • If a service is used only by a specific module, house it within that module.
  • For larger modules, consider breaking them down into submodules.
  • Only at the most granular level might a services folder exist, containing items exclusive to that particular module.

Consider an admin area with submodules for managing companies and their associated users. It makes sense to have a "users" module and a "companies" module, each providing its own UserService and CompanyService. But if the user detail page needs a dropdown listing all companies (to assign an employee), you must now access the CompanyService from the "users" module. Since that service lives inside the "companies" module, you'd need to lift it up to the parent "admin" module so both child modules can share it. Expect to perform similar refactors in analogous situations.

The example below illustrates a folder structure built on good frontend architecture principles:

├───app
│ │ app-routing.module.ts
│ │ app.component.ts
│ │ app.module.ts
│ │
│ ├───admin
│ │ │ admin.component.ts
│ │ │ admin.module.ts
│ │ │ admin.routing.ts
│ │ │
│ │ ├───companies
│ │ │ companies.component.ts
│ │ │ companies.module.ts
│ │ │ companies.routing.ts
│ │ │
│ │ │───services
│ │ │ companies.service.ts
│ │ │
│ │ └───users
│ │ │ users.component.ts
│ │ │ users.module.ts
│ │ │ users.routing.ts
│ │
│ │───services
│ │ users.service.ts
│ │
│ └───common
│ │ common.module.ts
│ │
│ ├───directives
│ │ error-highlight.directive.ts
│ │
│ ├───pipes
│ │ includes.pipe.ts
│ │
│ └───services
│ local-storage.service.ts
Enter fullscreen mode Exit fullscreen mode

The associated demo application is available here.

#5: Subscribing to observables by hand

At its core, manually subscribing to an Observable means you're writing imperative code. So why would you do it? If there's no imperative action to perform, a subscription is pointless—anything declarative can be achieved with RxJS operators and the AsyncPipe. Keep in mind, though, that AsyncPipe doesn't deal with error or completion callbacks. The guiding principle: only subscribe manually when you must execute an imperative operation that has no declarative alternative. A typical case is toggling a FormControl's enabled state based on the latest value from a stream. Since enable and disable methods are inherently imperative, subscribing is the only way forward.

#4: Components that balloon in size

Picture an entire application stuffed into a single component. It sounds absurd, but we've seen it. The same principle applies to smaller-scale versions: if you have one sprawling component per page or feature, you're missing the mark.

When a component handles too much, Angular struggles with performance, as every change triggers a full re-evaluation of all its data bindings. Worse, you leave behind a codebase that's a nightmare for your teammates—or your future self—to maintain.

Components tend to grow unwieldy when they take on too many jobs. Ideally, a component should act as a thin layer connecting user interactions and app events to the UI. Here's what a component should handle:

  • Interacting with the DOM
  • Displaying data sourced from services or stores
  • Managing its own lifecycle hooks
  • Handling forms, whether template-driven or reactive
  • Capturing and responding to user input
  • Passing data down to child components

And here's what a component should not do:

  • Fetch data directly
  • Alter global state
  • Touch storage APIs directly, like cookies or localStorage
  • Manage real-time connections, such as WebSockets, on its own
  • Handle custom DOM logic, like highlighting invalid inputs—extract that into a service for reusability

Variation: Services that balloon in size

  • We sometimes fail to keep our services well-organized.
  • Services that handle external data (fetched via HTTP, for instance) should generally be grouped by feature.
  • But logic can bleed across boundaries. Take an ArticleService that suddenly starts making HTTP calls to create or update bookmarks and tags—that's a clear breach of the Single Responsibility principle. Its proper role is CRUD: adding, removing, retrieving, sorting, and filtering articles.
  • To prevent this, classify services by the data features they own, and keep them separate from abstraction-layer services (like adapters for third-party libraries).

#3: Embedding complex logic in templates

Though declarative templates are a plus, they're not the place for intricate logic—whether it's presentational or anything else. Strict template type-checking does catch typos and type mismatches, but that's about it.

Logic in templates forces you to test through the DOM, which is slower than unit tests because the template must be compiled and a lot of setup must occur. Furthermore, template-embedded logic is inherently non-reusable.

At a bare minimum, move template logic into the component class. Ideally, however, push all logic into services: presentational logic into a presenter service, and business logic into dedicated service types. See #4: Big, hairy components for details.

#2: Declaring everything in AppModule

Let's be honest: NgModules are arguably Angular's most criticized feature. They're tough to explain, tricky to maintain, and often a source of confusion. So a particularly bad move is dumping all imports, exports, and declarations into the root AppModule. That not only breaks separation of concerns but also ensures the AppModule becomes bloated as the app grows. Fortunately, there's a clean workaround:

  1. Create feature modules and place their respective component declarations there.
  2. Build a SharedModule for components, pipes, directives, and services used across multiple modules.

But bullet two can become a sin of its own if we're not careful:

Variation: Overstuffing SharedModule with declarations

To avoid turning SharedModule into a dumping ground, consider grouping shared dependencies within feature modules instead. For example, if AdminModule contains UserModule and AccountModule, and both child modules depend on a common ManagementService, place that service inside AdminModule rather than at the app level. This way, each feature module can own its own scoped shared module.

#1: Sticking with imperative code and default change detection

Some sins are more forgivable than others. Angular, despite being built on RxJS, still nudges you toward imperative patterns: state is a mutable object, and the framework's change detection updates the DOM in response. But this approach has downsides:

  • Imperative code is verbose and hard to follow; you often have to read a lot of code just to understand how a piece of state changes.
  • It revolves around mutation: you keep modifying the same object reference, which leads to baffling bugs—state changes, but you can't trace where or why.
  • Default change detection is decent, but it still runs many steps you could easily skip.

Here's how to make amends:

  • Shift from imperative to declarative style; embrace functional programming practices, write pure functions, be explicit, and use composition.
  • Lean on RxJS Observables and operators, and model your state and its transitions as streams.
  • Stop mutating data manually; adopt ChangeDetectionStrategy.OnPush and pair Observables with the async pipe.
  • Consider adopting a state management library like NgRx.

Wrapping Up

Numerous pitfalls await when building a frontend application, and this guide concentrated on the most frequent and significant mistakes developers make with Angular. As you go through your code and address these issues, you will likely find that your project becomes more maintainable, predictable, and straightforward to work with.