| description | Vibe coding guidelines and architectural constraints for Angular within the frontend domain. | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| technology | Angular | ||||||||||||||||||||
| domain | frontend | ||||||||||||||||||||
| level | Senior/Architect | ||||||||||||||||||||
| version | 20 | ||||||||||||||||||||
| tags |
|
||||||||||||||||||||
| ai_role | Senior Angular Performance Expert | ||||||||||||||||||||
| last_updated | 2026-03-22 |
- Primary Goal: Enforce strict adherence to modern Angular v20 patterns, specifically Zoneless reactivity and functional APIs for optimal best practices.
- Target Tooling: Cursor, Windsurf, Antigravity.
- Tech Stack Version: Angular 20
Important
Strict Constraints for AI:
- Always use
signal(),computed(), andeffect()instead of RxJSBehaviorSubjectfor local state. - Never use
@Input()or@Output()decorators; strictly useinput()andoutput()functional APIs. - Always utilize the built-in control flow (
@if,@for,@switch) instead of structural directives (*ngIf,*ngFor).
Context: Component Inputs
@Input() title: string = '';The @Input() decorator operates outside the Signals reactivity system. Changes are not tracked granularly, requiring checks of the entire component tree (Dirty Checking) via Zone.js.
title = input<string>('');Use Signal Inputs (input()). This allows Angular to precisely know which specific component requires an update, paving the way for Zoneless applications.
Context: Component Outputs
@Output() save = new EventEmitter<void>();The classic EventEmitter adds an unnecessary layer of abstraction over RxJS Subject and does not integrate with the Angular functional API.
save = output<void>();Use the output() function. It provides strict typing, better performance, and a unified API with Signal Inputs.
Context: Model Synchronization
@Input() value: string;
@Output() valueChange = new EventEmitter<string>();Boilerplate code that is easy to break if you make a mistake in naming the Change event.
value = model<string>();Use model(). This creates a Signal that can be both read and written to, automatically synchronizing its state with the parent.
Context: Template Control Flow
<div *ngIf="isLoaded; else loading">
<li *ngFor="let item of items">{{ item }}</li>
</div>Directives require importing CommonModule or NgIf/NgFor, increasing bundle size. Micro-template syntax is complex for static analysis and type-checking.
@if (isLoaded()) {
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
} @else {
<app-loader />
}Use the built-in Control Flow (@if, @for). It is built into the compiler, requires no imports, supports improved type-narrowing, and runs faster.
Context: Data Fetching
data: any;
ngOnInit() {
this.service.getData().subscribe(res => this.data = res);
}Imperative subscriptions lead to memory leaks (if you forget to unsubscribe), "Callback Hell", and state desynchronization. Requires manual subscription management.
data = toSignal(this.service.getData());Use toSignal() to convert an Observable into a Signal. This automatically manages the subscription and integrates the data stream into the reactivity system.
Context: Component State Management
private count$ = new BehaviorSubject(0);
getCount() { return this.count$.value; }RxJS is overkill for simple synchronous state. BehaviorSubject requires .value for access and .next() for writes, increasing cognitive load.
count = signal(0);
// Access: count()
// Update: count.set(1)Use signal() for local state. It is a primitive designed specifically for synchronizing UI and data.
Context: Reactivity
ngOnChanges(changes: SimpleChanges) {
if (changes['firstName']) {
this.fullName = `${this.firstName} ${this.lastName}`;
}
}ngOnChanges is triggered only when Inputs change, has complex typing, and runs before View initialization.
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);Use computed(). The signal is recalculated only when its dependencies change, and the result is memoized (cached).
Context: DI Pattern
constructor(private http: HttpClient, private store: Store) {}Constructors become cluttered with many dependencies. When inheriting classes, dependencies must be passed through super().
private http = inject(HttpClient);
private store = inject(Store);Use the inject() function. It operates in the initialization context (fields or constructor), is type-safe, and does not require super() during inheritance.
Context: App Architecture
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule]
})
export class AppModule {}Modules create an unnecessary level of indirection. Components become dependent on the module context, complicating Lazy Loading and testing.
@Component({
standalone: true,
imports: [CommonModule]
})Use Standalone Components. This is the Angular v14+ standard that makes components self-sufficient and tree-shakable.
Context: Lazy Loading Routing
loadChildren: () => import('./module').then(m => m.UserModule)Loading modules pulls in transitive dependencies that might not be needed.
loadComponent: () => import('./user.component').then(c => c.UserComponent)Use loadComponent for routing to Standalone components. This ensures minimal chunk size.
Context: Template Performance
<div>{{ calculateTotal(items) }}</div>The calculateTotal function is called during every Change Detection (CD) cycle, even if items have not changed. This kills UI performance.
total = computed(() => this.calculateTotal(this.items()));<div>{{ total() }}</div>Extract logic into computed() signals or Pure Pipes. They are only executed when input data changes.
Context: RxJS Memory Leaks
destroy$ = new Subject<void>();
ngOnDestroy() { this.destroy$.next(); }
stream$.pipe(takeUntil(this.destroy$)).subscribe();It's easy to forget takeUntil or unsubscribe. Requires a lot of boilerplate code in every component.
stream$.pipe(takeUntilDestroyed()).subscribe();Use the takeUntilDestroyed() operator. It automatically unsubscribes upon context destruction (component, directive, service).
Context: Prop Drilling
<!-- Parent -->
<app-child [theme]="theme"></app-child>
<!-- Child -->
<app-grandchild [theme]="theme"></app-grandchild>"Prop drilling" heavily couples intermediate components to data they don't need, just for the sake of passing it deeper.
// Service
theme = signal('dark');
// Grandchild
theme = inject(ThemeService).theme;Use Signal Stores or services for state sharing, or the new input() API with context inheritance (in the future).
Context: Security & Abstraction
el.nativeElement.style.backgroundColor = 'red';Direct DOM access breaks abstraction (doesn't work in SSR/Web Workers) and opens up XSS vulnerabilities. It bypasses Angular Sanitization mechanisms.
// Use Renderer2 or bindings
<div [style.background-color]="color()"></div>Use style/class bindings or Renderer2. For direct manipulations, consider directives.
Context: Change Detection
The application relies on Zone.js for any asynchronous event (setTimeout, Promise, XHR).
Zone.js patches all browser APIs, adding overhead and increasing bundle size. CD triggers more often than necessary.
bootstrapApplication(App, {
providers: [provideExperimentalZonelessChangeDetection()]
});Migrate to Zoneless mode. Use Signals to notify Angular when a re-render is needed.
Context: Tree Shaking
@NgModule({ providers: [MyService] })The service is included in the bundle even if it is not used.
@Injectable({ providedIn: 'root' })Always use providedIn: 'root'. This allows the bundler to remove unused services (Tree Shaking).
Context: Routing Security
@Injectable()
export class AuthGuard implements CanActivate { ... }Class-based guards require more code and injections. They are less flexible for composition.
export const authGuard: CanActivateFn = (route, state) => {
return inject(AuthService).isLoggedIn();
};Use functional Guards (CanActivateFn). They are concise, easy to test, and composable.
Context: HTTP Requests
@Injectable()
export class TokenInterceptor implements HttpInterceptor { ... }Similar to guards: lots of boilerplate, complex registration in the providers array.
export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
return next(req.clone({ setHeaders: { Authorization: token } }));
};Use functional Interceptors (HttpInterceptorFn) with provideHttpClient(withInterceptors([...])).
Context: Data Integrity
updateUser(user: User) {
this.currentUser = user; // Mutable assignment
}Object mutations complicate change tracking and can lead to unpredictable behavior in components using the OnPush strategy.
currentUser = signal<User | null>(null);
updateUser(user: User) {
this.currentUser.set({ ...user }); // Immutable update
}Use Signals for state management. They guarantee reactivity and atomicity of updates.
Context: Rendering Performance
@for (item of items; track getItemId(item))The tracking function is called for each element during every re-render.
@for (item of items; track item.id)Use an object property (ID or a unique key) directly. If a function is needed, it must be as simple and pure as possible.
Context: Component Metadata
@HostListener('click') onClick() { ... }
@HostBinding('class.active') isActive = true;Decorators increase class size and scatter host configuration across the file.
@Component({
host: {
'(click)': 'onClick()',
'[class.active]': 'isActive()'
}
})Use the host property in component metadata. This centralizes all host element settings.
Context: Dynamic Rendering
const factory = this.resolver.resolveComponentFactory(MyComponent);
this.container.createComponent(factory);ComponentFactoryResolver is deprecated. It is an old imperative API.
this.container.createComponent(MyComponent);
// Or strictly in template
<ng-container *ngComponentOutlet="componentSignal()" />Use ViewContainerRef.createComponent directly with the component class or the ngComponentOutlet directive.
Context: Modular Architecture
SharedModule imports and exports all UI components, pipes, and directives.
If a component needs a single button, it is forced to pull the entire SharedModule. This breaks Tree Shaking and increases the initial bundle size.
Import only what is needed directly into the imports of the Standalone component.
Abandon SharedModule in favor of granular imports of Standalone entities.
Context: Architecture
Service A injects Service B, which injects Service A.
Leads to runtime errors ("Cannot instantiate cyclic dependency"). Indicates poor architectural design.
Use forwardRef() as a crutch, but it's better to extract the shared logic into a third Service C.
Refactoring: break services into smaller ones following SRP (Single Responsibility Principle).
Context: Separation of Concerns
A Pipe performs HTTP requests or complex business logic.
Pipes are intended for data transformation in the template. Side effects in pipes violate function purity and kill CD performance.
Pipes should be "Pure" (without side effects) and fast.
Extract logic into services/signals. Leave only formatting to pipes.
Context: TypeScript Safety
getData(): Observable<any> { ... }any disables type checking, nullifying the benefits of TypeScript. Errors only surface at runtime.
getData(): Observable<UserDto> { ... }Use DTO interfaces (generate them from Swagger/OpenAPI) and Zod for API response validation.
Context: RxJS Subscriptions
<div *ngIf="user$ | async as user">{{ (user$ | async).name }}</div>Each async pipe creates a new subscription. This can lead to duplicated HTTP requests.
@if (user$ | async; as user) {
<div>{{ user.name }}</div>
}Use aliases in the template (as varName) or convert the stream to a signal (toSignal).
Context: DI Scopes
@Injectable({ providedIn: 'any' })Creates a new service instance for each lazy-loaded module. This is often unexpected behavior, leading to state desynchronization (different singleton instances).
providedIn: 'root' or providing at the level of a specific component (providers: []).
Avoid any. Explicitly control the scope: either global (root) or local.
Context: Navigation
this.router.navigateByUrl('/users/' + id);Hardcoding route strings makes route refactoring a pain.
this.router.navigate(['users', id]);Use an array of segments. It is safer (automatic encoding of URL parameters) and cleaner.
Context: Change Detection Strategy
Default components (ChangeDetectionStrategy.Default).
Angular checks this component on every app event, even if the component data hasn't changed.
changeDetection: ChangeDetectionStrategy.OnPushAlways set OnPush. With signals, this becomes the de facto standard, as updates occur precisely.
Context: Bundle Size
<app-chart [data]="data" />A charting library (e.g., ECharts) loads immediately, blocking TTI (Time to Interactive), even if the chart is below the "fold".
@defer (on viewport) {
<app-chart [data]="data" />
} @placeholder {
<div>Loading chart...</div>
}Use @defer. This defers component code loading until a trigger occurs (viewport, interaction, timer).
Context: Event Loop Blocking
Sorting an array of 100k elements directly in the component.
Freezes the UI.
Offload computations to a Web Worker.
Use Angular Web Workers. In v20, this is easily configured via the CLI.
Context: Signal Effects
effect(() => {
const timer = setInterval(() => ..., 1000);
// No cleanup
});Effects restart when dependencies change. If you don't clean up timers/subscriptions inside an effect, they accumulate.
effect((onCleanup) => {
const timer = setInterval(() => ..., 1000);
onCleanup(() => clearInterval(timer));
});Always use the onCleanup callback to release resources.
Context: Zone Integration
Wrapping third-party libraries in ngZone.run() unnecessarily.
Forces redundant checks of the entire component tree.
ngZone.runOutsideAngular(() => {
// Heavy chart rendering or canvas animation
});Run frequent events (scroll, mousemove, animationFrame) outside the Angular zone. Update signals only when UI updates are required.
Context: Signal Performance
data = signal({ id: 1 }, { equal: undefined }); // Default checks referenceIf you create a new object with the same data { id: 1 }, the signal triggers an update, even though the data hasn't fundamentally changed.
import { isEqual } from 'lodash-es';
data = signal(obj, { equal: isEqual });Use a custom comparison function for complex objects to avoid redundant re-renders.
Context: Re-rendering Lists
<li *ngFor="let item of items">{{ item }}</li>Without tracking, any array change leads to the recreation of all DOM nodes in the list.
@for (item of items; track item.id)Always use a unique key in track. This allows Angular to move DOM nodes instead of recreating them.
Context: Tree Rendering
Recursive component call without OnPush and memoization.
Exponential growth in change detection checks.
Using the Memoization pattern or computed() to prepare the tree data structure.
Context: CSS Encapsulation
/* global.css */
button { padding: 10px; }Global styles unpredictably affect components.
Use ViewEncapsulation.Emulated (default) and specific selectors.
Keep styles locally within components.
Context: Split Chunks
A single huge component of 3000 lines.
Poor readability, rendering lazy loading of UI parts impossible.
Decompose into "dumb" (UI) and "smart" components.
Break down the UI into small, reusable blocks.
Context: Core Web Vitals (LCP)
<img src="large-hero.jpg" />The browser loads the full image, shifting the layout (CLS).
<img ngSrc="hero.jpg" width="800" height="600" priority />Use the NgOptimizedImage directive. It automatically handles lazy loading, preconnect, and srcset.
Context: SSR / SSG
Rendering Date.now() or random numbers (Math.random()) directly in the template.
The server generates one number, the client another. This causes "flickering" and a hydration error; Angular discards the server DOM and renders from scratch.
Use stable data or defer random generation until afterNextRender.
Pay attention to template determinism with SSR.
Context: DI Performance
Calling inject() inside a function that loops.
Although inject is fast, in hot paths these are unnecessary DI tree lookups.
Inject dependency once at the class/file constant level.
Context: Signal Graph
Reading a signal inside computed whose value doesn't affect the result (an unexecuted logical branch).
Angular dynamically builds the dependency graph. If you accidentally read a signal, it becomes a dependency.
Use untracked() to read signals whose changes should not trigger a recalculation.
Context: DOM Size
<div><div><div><app-comp></app-comp></div></div></div>Increases DOM tree depth, slowing down Style Recalculation and Layout.
Use <ng-container> to group elements without creating extra DOM nodes.
Context: High-frequency events
@HostListener('window:scroll')
Every scroll event triggers Change Detection.
Subscribe manually in runOutsideAngular and update the signal only when necessary.
Context: Form Safety
[(ngModel)] without strict model typing.
Risk of assigning a string to a numeric field.
Use Reactive Forms with FormControl<string> typing or new Signal-based Forms (when out of developer preview).
Context: Reactive Forms
const form = new FormGroup({ ... }); // Untypedform.value returns any.
const form = new FormGroup<LoginForm>({
email: new FormControl('', { nonNullable: true }),
...
});Always type forms. Use nonNullable: true to avoid string | undefined hell.
Context: RxJS Patterns
this.route.params.subscribe(params => {
this.api.getUser(params.id).subscribe(user => ...);
});Classic Race Condition. If parameters change rapidly, response order is not guaranteed.
this.route.params.pipe(
switchMap(params => this.api.getUser(params.id))
).subscribe();Use Flattening Operators (switchMap, concatMap, mergeMap).
Context: Network Efficiency
Ignoring request cancellation when navigating away from the page.
Requests continue hanging, consuming traffic.
HttpClient automatically supports cancellation upon unsubscription. With signals: ensure rxResource or the effect correctly cancels the request.
Context: Unidirectional Data Flow
this.inputData.push(newItem);The parent component remains unaware of the change. Violates the One-Way Data Flow principle.
Emit event (output) upwards; the parent changes the data and passes the new object downwards.
Context: Form Mixing
Using formControlName and [(ngModel)] on the same input.
Deprecated behavior. Causes form and model synchronization conflicts.
Use only one approach: either Reactive or Template-driven.
Context: Form Logic
Validation via HTML attributes for complex logic.
Hard to test, no reusability.
Custom Validator Functions or Async Validators in the component class.
Context: Performance
Validating a complex field on every keystroke (change).
Slows down user input.
new FormControl('', { updateOn: 'blur' });Trigger validation/update only when the user has finished typing.
Context: UX
.subscribe(data => ...) without an error callback.
On a 500 error, the application "hangs" in a loading state.
Global Error Handler or catchError in the pipe returning a safe value.
Context: Maintainability
http.get('https://api.com/users')
Inability to switch environments (dev/prod).
Using InjectionToken API_URL and environment configuration.
Context: Fine-grained Reactivity
Accidentally creating a cyclic dependency in computed.
Error: Detected cycle in computations.
computed(() => {
const user = this.user();
untracked(() => this.logger.log(user)); // Logging doesn't create dependency
return user.name;
});Use untracked() for side effects or reads that shouldn't affect recalculation.
57. V8 Hidden Classes Optimization
Context: Micro-optimization
user = signal({});
// later
user.set({ name: 'A', age: 10 }); // Shape changeInitializing with an empty object and later adding fields changes the object "shape" (Hidden Class), breaking V8 JIT compiler optimization.
interface User { name: string | null; age: number | null; }
user = signal<User>({ name: null, age: null });Always initialize signals with the full object shape (even with null) to preserve property access monomorphism.
Context: Reactivity Theory
Relying on effect to fire synchronously.
Signals guarantee "Glitch Freedom" (absence of intermediate inconsistent states), but effects are asynchronous (microtask timing).
Do not use effects to synchronize local state. Use computed.
Context: Application Lifecycle
Creating an effect in a service without manualCleanup.
Effects in root services live forever. If they subscribe to something global, it can leak.
Usually fine, but if the service is destroyed (rare lazy loading case), the effect must be cleaned up with effectRef.destroy().
Context: Advanced DI
Passing an Injector instance manually into functions.
Bulky code.
runInInjectionContext(this.injector, () => {
// can use inject() here dynamically
const service = inject(MyService);
});Use this helper to execute functions requiring a DI context outside the constructor/initialization.