Skip to main content

OnPush and Signals as Default, Not Exception: 1215 Components in an Angular 21 Monorepo

1215 out of 1219 components in this portfolio use ChangeDetectionStrategy.OnPush. Why "turn it on when I need it" is a comfortable lie, what immutability discipline actually costs, and when Default is the better call instead.

What do you think about this article?

OnPush and Signals as Default, Not Exception: 1215 Components in an Angular 21 Monorepo
Angular Modernofedericocalo.dev

At a Glance

1215 out of 1219 components in this portfolio use ChangeDetectionStrategy.OnPush. Why "turn it on when I need it" is a comfortable lie, what immutability discipline actually costs, and when Default is the better call instead.

Direct answer

In this Angular 21 monorepo, 1215 out of 1219 components (99.7%) use ChangeDetectionStrategy.OnPush, not because of a top-down policy but because the internal scaffolding generates it by default from line one. The real lesson is not about syntax: immutability discipline has to be enforced from the start, not retrofitted onto a component that already mutates its @Input in silence.

"OnPush is opt-in, I turn it on when I need it" is the most comfortable and most dishonest sentence people say about Angular change detection strategies. There is no "when I need it": either you force it from day one on every component, or in a growing monorepo (in this case 3884 TypeScript files, 1219 standalone components) you never actually get there, because retrofitting OnPush onto an existing component means hunting down every hidden direct mutation in its children, and at that point it costs more than rewriting it.

Method disclaimer: the numbers in this article come from a direct grep on the source code of this same portfolio (grep -rl "ChangeDetectionStrategy.OnPush" src/app and similar), not from a slide deck or a third-party blog post. They are verifiable by anyone with access to the repository.

The datum: 1215 out of 1219 components

The exact count, as of today, over frontend/src/app:

  • 1219 files with a @Component( decorator.
  • 1215 files explicitly declaring ChangeDetectionStrategy.OnPush (99.7%).
  • 1090 calls to signal( in the source code.
  • 1501 calls to computed(.
  • 64 calls to effect(.

The four components outside that 99.7% are not a deliberate exception: they are a residue, almost certainly components written before the rule became automatic in the internal scaffolding, never touched again because "they work". That is exactly the point of this article: once a component falls outside the discipline, the cost of bringing it back in grows over time, it does not shrink.

Why "opt-in" is a comfortable lie

Angular's official documentation presents OnPush as an optional optimization: the component only re-renders when one of its @Input references changes, not on every zone change detection cycle. In theory you can introduce it component by component, "when performance requires it".

In practice, in a monorepo with hundreds of nested components, change detection strategy is not a local property: it is a property of the whole tree. If a Default-strategy parent passes a mutated object in-place to an OnPush child, the child stops updating. No error is thrown, there is no stack trace: the data on screen is simply stale. Bugs like this get discovered by accident, months later, when someone notices a counter that does not update after a user action.

That is why "I turn on OnPush when I need it" is comfortable to say and dishonest to do: it means postponing the discipline until the retrofit costs a rewrite, not a flag.

The hidden cost: mandatory immutability

OnPush is not free. The implicit rule it carries is: never mutate an object or array passed as an @Input, always create a new copy (spread, map, filter). This is, not by coincidence, the same immutability rule this project also enforces on the Java side (immutable records) and the Python side (frozen Pydantic models): it is not an Angular-only choice, it is a cross-stack discipline.

The real cost is twofold. First, onboarding: a junior developer coming from a more permissive framework (or from Angular with Default strategy) instinctively writes this.items.push(x) instead of this.items.set([...this.items(), x]), and the first bug they produce is silent, not a compile error. Second, initial debugging: when the UI "does not update" under OnPush, the cause is almost always a direct mutation upstream, but finding it requires walking up the component tree until you identify who mutated what.

With the Default strategy, that exact same direct-mutation bug "still works", because Angular re-renders everything on every cycle regardless of references. This is precisely why Default feels simpler at first and gets more expensive at scale: it hides the problem instead of preventing it.

Signals make the discipline nearly automatic

The datum that surprised me the most while writing this article is not the 99.7% OnPush figure: it is the ratio between signal() (1090) and computed() (1501). There are more computed derivations than direct state sources. In practice, primitive state gets declared once and then derived, not manually recalculated in ten different places inside a component.

With Signals, the immutability OnPush requires stops being a rule you have to remember and becomes a natural consequence of the API: signal.set(newValue) or signal.update(prev => ...) do not allow direct mutation the way a public class field did. The 64 calls to effect( stay deliberately few: they are the escape valve for real side effects (syncing with a StorageService, derived HTTP calls), not the primary update mechanism.

When forcing OnPush is not worth it

It would be dishonest to present OnPush as a choice with no downsides. On a throwaway prototype, an internal demo that lives two weeks, or a component with two static @Inputs that never change after initialization, the cost of immutability discipline outweighs the benefit: the Default strategy does the same job with less cognitive friction, and nobody will ever pay the debugging cost of a direct mutation because that component will not live long enough to accumulate that kind of bug.

The real trade-off is not "OnPush always" versus "Default always": it is deciding, BEFORE writing the first line of a component meant to live and grow, which discipline to apply, because changing it later costs almost as much as rewriting it.

What I would do differently

If I had to start over, I would not change the choice of OnPush as the default: I would confirm it again, with more conviction. What I would change is the moment I made it automatic in the scaffolding: the four components outside that 99.7% exist because for a period the rule was "recommended" instead of "generated by the template". The operational lesson: if an architectural discipline is worth having, it needs to be enforced structurally (linter, scaffolding template, blocking code review), not left to the memory of whoever is writing the code at the time.


Frequently asked questions

What is ChangeDetectionStrategy.OnPush in Angular?

It is a change detection strategy that tells Angular to re-render a component only when one of its @Input references changes, an event on the component itself fires, or a Signal read in the template changes value, instead of rechecking the component on every zone change detection cycle.

Why is Angular's Default strategy considered a problem at scale?

Because it hides direct-mutation bugs instead of preventing them: with Default, Angular re-renders the component on every cycle regardless of references, so an incorrect mutation of an object passed as an @Input produces no visible symptoms until you switch to OnPush, at which point the retrofit costs far more than adopting it from the start.

What are the real downsides of OnPush?

It requires mandatory immutability discipline (never mutate objects or arrays in @Input directly), which brings a higher onboarding cost for developers used to more permissive patterns and a harder initial debugging session when the UI silently stops updating because of an upstream direct mutation.

How do OnPush and Signals fit together in Angular 21?

Signals make the immutability discipline OnPush requires nearly automatic: the signal.set()/signal.update() API does not allow direct mutation the way a public class field did, and computed() values update in a traceable way without manual recalculation scattered across the component.

Is it worth forcing OnPush on every component without exceptions?

Not always: on throwaway prototypes, short-lived demos, or components with very few static Inputs, the discipline cost outweighs the benefit. The real trade-off is deciding the strategy before writing the first line of a component meant to grow, because changing it later costs almost as much as rewriting it.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects