angular.benjavicente.dev

My opinion on Angular

Angular is a weird framework. It is a framework that abused a TypeScript feature to read type information at runtime to provide a really powerful dependency injection system, a framework where every component is a DOM element, and where the architecture can't escape class-based patterns.

As with a lot of frameworks, you can judge it by looking at 2 sides of the coin: the platform, meaning the principles, philosophy, and processes around it; and the framework, meaning the technical solution provided by that platform.

Angular as a platform

Great surface area, low guidance

Angular is the opposite of React when we talk about how opinionated those are.

React offers high guidance with a small surface area. Angular covers a large surface area with less guidance. Guidance Surface Area React Angular

Usually it is said that Angular is more opinionated than React, and that is true because Angular covers a substantially larger surface area of opinions than React. React guidance is mostly only on rendering and component state, but it provides a lot more than Angular in that aspect.

The result of that is that Angular is great if you want to avoid externalizing more opinions thanks to its batteries-included approach, but when frameworks are mature enough, libraries arise to fill the gaps. And React has an incredible ecosystem.

But even there, if the core library aims to provide everything you need, what is the point of library innovation? There are lower incentives to maintain and contribute to libraries if the core framework provides an alternative and people, tooling and AI will tend to use the core framework instead.

Self-contained ecosystem

Angular is the opposite of Vue when we talk about the ecosystem they create.

It is natural that framework authors are the ones creating tools for their frameworks, but if you compare the results of Vue and Angular, it is a completely different story. The Vue local server evolved into Vite. From Nuxt we got the UnJS ecosystem, that includes tools like Nitro. From Vite we got Vitest. From Vue tooling we got Volar.

And also the incredible support Vite has been building as a foundation for other frameworks, from great ecosystem support to their internal toolchain.

While Angular has supported some innovations, like the signals proposal, and Zone.js as an example of how asynchronous context sharing might be useful, most things are in a self-contained ecosystem that covers a greater surface area than the rest of the frameworks.

Google governance

This can come as a weird difference between other frameworks. Angular is the only big framework where you can “feel” the influence of the company that owns it.

You can see recommendations for Google products, like Firebase and Gemini, in their docs. The way they communicate announcements is different from the rest, and sometimes some things might feel forced to showcase other Google innovations and goals, from Chrome to incentivize AI usage.

As with the other points in this section, this is not a bad thing by itself, but it is definitely something off-putting for me.

Angular as a framework

Dependency injection is too powerful

DI is really great to provide implementation details and dependencies to the whole application, but the patterns that can emerge with Angular's DI are too powerful. The lazy creation of local services creates weird lifecycle states that are hard to understand at a glance. For example this code:

const parentCreatesIt = true;

@Injectable()
class Service {
  parent = parentCreatesIt ? { name: "Service" } : inject(Parent)
}

@Component({
  selector: 'app-child',
  template: `{{ service.parent.name }}`,
})
class Child {
  service = inject(Service)
}

@Component({
  selector: 'app-parent',
  template: `The one above all is the <app-child/>`,
  imports: [Child],
  providers: [Service],
})
class Parent {
  name = "Parent"
  service: any = parentCreatesIt ? inject(Service) : undefined
}

The service is provided in the parent, but it isn't created yet. So when the child component injects it, the service is created in a context where it can read the parent component. If the parent now injects it in its component body, it will raise a circular dependency error (remove parentCreatesIt to see it).

So what context can the service read depends not only on where it has been provided, but also where it is first injected. I believe the intuitive thing would be that services are lazy-loaded, but the location is explicit. In the example, it would be created above the parent component context.

If you consider that some services also could end up with state and lifecycle logic, this could lead to weird unexpected behavior, from requests that aren't made as early as expected to unexpected errors due to circular dependencies or state of a parent context that depends on the initialization of a child.

Finally, the DI architecture might be the biggest blocker for hot module replacement of component code, with their lazy initialization and reference tracking that could be really hard to track on a code update.

So while DI is really great to provide global services and settings with great tree shaking due to the lazy initialization, the model becomes harder to track when local services are involved.

Low static analysability

Consider this code:

@Component({
  selector: 'app-demo',
  template: `<button (click)="onClick()">Start</button>`,
})
class DemoComponent {
  count$ = interval(1000).pipe(
    takeUntilDestroyed()
  );

  onClick(): void {
    interval(1000)
      .pipe(takeUntilDestroyed())
      .subscribe(value => console.log(value));
  }
}

The count$ works, but the onClick method does not, because takeUntilDestroyed requires it to run in the injection context. While you can pass a DestroyRef as an argument to remove that requirement, this is not obvious at a glance and can't easily be checked by static analysis.

Compare it to making the DestroyRef required, removing the implicit injection context read by the function:

@Component({
  selector: 'app-demo',
  template: `<button (click)="onClick()">Start</button>`,
})
class DemoComponent {
  destroyRef = inject(DestroyRef);

  count$ = interval(1000).pipe(
    takeUntilDestroyed(this.destroyRef)
  );

  onClick(): void {
    interval(1000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(value => console.log(value));
  }
}

Or if it followed a naming convention, like React or Vue, using inject as a prefix:

@Component({
  selector: 'app-demo',
  template: `<button (click)="onClick()">Start</button>`,
})
class DemoComponent {
  count$ = interval(1000).pipe(
    injectTakeUntilDestroyed()
  );

  onClick(): void {
    interval(1000)
      .pipe(injectTakeUntilDestroyed())
      .subscribe(value => console.log(value));
  }
}

The Angular team, and the community has not established a convention for this. New APIs are created without a naming pattern, and the community is slowly following that instead of using a pattern like the inject prefix.

Tools with static analysability have been really successful with AI, so not having that is a big disadvantage in this new era of development.

Multiple ways to abuse DI

Did I say that DI is too powerful? Well, it also provides multiple ways to break expectations on how it should be used.

First, most functions that require the injection context have a way to pass a injector as an argument. That doesn't scream “danger!” as much as the runInInjectionContext, and we are adding a second, and arguably unnecessary way to run things outside of the normal injection context flow.

Second, the injection context might or might not have lifecycle attached to it. Considering that most Angular building blocks depend on things in the injection context, it could lead to unexpected behavior that ideally should not work in the first place.

const guardWithEffect: CanActivateFn = () => {
  effect(() => {
    const interval = setInterval(() => {
      console.log('interval running from guard effect');
    }, 1000);
    onCleanup(() => {
      clearInterval(interval);
    });
  });
  return true;
};

const routes: Routes = [
  {
    path: 'guarded',
    component: GuardedPage,
    canActivate: [guardWithEffect],
  },
];

In most cases, it will create an effect that attaches per guard check and never detaches. It can be fixed by using the withExperimentalAutoCleanupInjectors feature to the router and adding a provider array to that route.

The main issue in both situations is that dependency injection could be abused as a service locator, allowing unexpected behavior, especially when lifecycle is involved.

Class components and inputs as attributes

The combination of using class components and attributes for inputs creates architectural constraints that are annoying to deal with in custom utilities and libraries.

@Component({
  selector: 'example-component',
  template: `<div>{{ name }}</div>`,
})
class ExampleComponent {
  name = input.required();
}

This has 2 limitations, a technical one and a practical one.

The technical limitation is that each input can't know its own name until the component initializes. In JS, the class body runs when the instance is created. The class needs to finish the construction before you can read any attributes, so a post-process is needed to bind the inputs, like the following pseudocode:

// Class body and constructor runs
c = runInInjectionContext(injector, () => new ExampleComponent());
// Inputs are attached post construction
for (const [attributeName, attribute] of Object.entries(c)) {
  if (isInput(attribute)) {
    bindInputValue(inputs, attributeName, attribute);
  }
}
// Component ends initialization
c?.ngOnInit();

Given that, code like this is invalid when required input (or model) signals are involved, but it works with optional inputs or any other signal:

function injectDelayedSetTitle(name: Signal<string>) {
  // Throws NG0952/NG0950: no model/input available yet
  document.title = name();
  effect((onCleanup) => {
    const t = setTimeout(() => {
      document.title = name();
    }, 1000);
    onCleanup(() => {
      clearTimeout(t);
    });
  });
}

class ExampleComponent {
  name = input.required();
  constructor() {
    injectDelayedSetTitle(this.name);
  }
}

So unlike Vue, Svelte and Solid 1.X, signals might not have a value yet, and will throw an error if accessed before component initialization.

The workaround is creating computed signals, linked signals, and using untracked to initialize or run code just after initialization, like in an effect that runs once, or, if it is pure, in the computed chain. I wrote a more detailed explanation in how to deal with it here, where I looked into the TanStack Angular Store adapter.

The other side is practical: you need to declare every attribute. If you just pass an object, you lose the reactive granularity of signals, since Angular does not use proxies like Solid does. And if you are adapting an object to be compatible with that, you need to manually keep each input signal in sync with the object properties.

Inputs per attribute are annoying in bigger components with more inputs. The only benefit I see is if you want to use component inheritance, but we know that composition is better than inheritance so that isn't good.

Weird composition semantics

There are some weird papercuts when dealing with composition in Angular:

Lack of APIs to invert responsibility

This is straightforward: loading and error boundaries, plus transition APIs, are essential for applications dealing with async state. Without those APIs, it is hard to build applications with async transitions, like when using lazy-loaded code and loaders, both asynchronous steps that run before a component is rendered.

Error boundaries are intended to be added in 2026 Q3, but I don't see them adding a suspense-like API that would allow a component to suspend effects and only resume them when the component is ready to render.

App based architecture instead of component based

Angular used a module-based architecture, where you defined some sort of “virtual modules” that provided things and exported others. With standalone components that is no longer needed, but we still replicate an import system through component imports.

There is also a tendency for application-level things to be tightly coupled into the system. For example, you can't create the HTTP client and provide it yourself; Angular creates it for you on application bootstrap. It isn't a big deal for most apps, but apps with weird setup requirements and Angular-based frameworks arrive at annoying limitations.

Finally, the Angular CLI. It combines tools like package.json scripts through targets and builders, thin wrappers around linting, testing, and e2e frameworks, plus scaffolding tools. With that, it can't integrate as nicely with emerging tools in the JS ecosystem that assume the common patterns that the other frameworks use.

While it is great that Angular provides a deep integration with their tools and libraries, not having an easy way to “eject” from parts of it is annoying, and makes it annoying, hard or cumbersome to integrate Angular with other frameworks and tools.

So, should you use Angular for new projects?

I think it depends on 1 thing: do you value more that your application depends on a single group of maintainers or not? A single group of maintainers will guarantee that the tools are aligned and, since it's a tool Google uses, it is at a lower risk of being abandoned.

If not, I think you shouldn't use Angular. It has architectural decisions that do not make it as powerful as other frameworks. The batteries included approach disincentivizes a great ecosystem around it, and it shows compared to Vue and React.

If you are already on Angular, don't jump ship without a good reason. The framework is really well maintained. With modern Angular, it is possible to use modern composition patterns that are aligned with the rest of the ecosystem, and framework-agnostic tools can help a lot on improving some of the gaps.

If you want to see how much “Angular” you can remove to use agnostic tools that are available in other frameworks, check out my Unreal World Angular demo.