16.8 Pro Tips — Change Detection Strategy, HttpClient, NgRx State Management Overview
Change Detection Strategy
Angular updates the DOM through Change Detection when data changes.
Default vs OnPush
import { Component, ChangeDetectionStrategy } from '@angular/core';
// Default: Inspects the entire component tree after every event
@Component({
selector: 'app-default',
template: `...`
})
export class DefaultComponent {}
// OnPush: Re-renders only when:
// 1. An Input reference changes
// 2. An event fires in the component or a child
// 3. An async pipe Observable emits
// 4. A Signal value changes
// 5. markForCheck() is called manually
@Component({
selector: 'app-optimized',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`
})
export class OptimizedComponent {}
OnPush + Signals (Optimal Combination)
import { Component, ChangeDetectionStrategy, signal, computed, input } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // Performance optimization
template: `
<div class="product-card" [class.selected]="isSelected()">
<h3>{{ product().name }}</h3>
<p>{{ formattedPrice() }}</p>
<button (click)="toggleSelect()">
{{ isSelected() ? 'Deselect' : 'Select' }}
</button>
</div>
`
})
export class ProductCardComponent {
product = input.required<{ id: number; name: string; price: number }>();
private _isSelected = signal(false);
readonly isSelected = this._isSelected.asReadonly();
// computed works seamlessly with OnPush
formattedPrice = computed(() =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
.format(this.product().price)
);
toggleSelect() {
this._isSelected.update(v => !v);
}
}
HttpClient Full Usage
Basic Setup
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor, errorInterceptor, loadingInterceptor } from './interceptors';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor, // Auto-attach token
errorInterceptor, // Global error handling
loadingInterceptor // Loading state management
])
)
]
};
HTTP Interceptors (Functional, Angular 15+)
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next(authReq);
}
return next(req);
};
// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
switch (error.status) {
case 401:
// Auth expired → login page
router.navigate(['/login'], {
queryParams: { returnUrl: router.url }
});
break;
case 403:
router.navigate(['/403']);
break;
case 404:
console.error('Resource not found:', req.url);
break;
case 500:
console.error('Server error:', error.message);
break;
}
return throwError(() => error);
})
);
};
// interceptors/loading.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { LoadingService } from '../services/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
// Skip loading indicator for certain URLs
const skipLoading = req.headers.has('X-Skip-Loading');
if (skipLoading) return next(req);
loadingService.show();
return next(req).pipe(
finalize(() => loadingService.hide())
);
};
Type-Safe HttpClient Requests
// services/api.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
private baseUrl = environment.apiUrl;
get<T>(path: string, params?: Record<string, any>): Observable<T> {
let httpParams = new HttpParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
httpParams = httpParams.set(key, String(value));
}
});
}
return this.http.get<T>(`${this.baseUrl}${path}`, { params: httpParams });
}
post<T>(path: string, body: unknown): Observable<T> {
return this.http.post<T>(`${this.baseUrl}${path}`, body);
}
put<T>(path: string, body: unknown): Observable<T> {
return this.http.put<T>(`${this.baseUrl}${path}`, body);
}
patch<T>(path: string, body: unknown): Observable<T> {
return this.http.patch<T>(`${this.baseUrl}${path}`, body);
}
delete<T>(path: string): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}${path}`);
}
}
Environment Configuration
// src/environments/environment.ts (development)
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
wsUrl: 'ws://localhost:3000',
featureFlags: {
newCheckout: true,
darkMode: true
}
};
// src/environments/environment.production.ts (production)
export const environment = {
production: true,
apiUrl: 'https://api.myapp.com',
wsUrl: 'wss://api.myapp.com',
featureFlags: {
newCheckout: true,
darkMode: false
}
};
// File replacement config in angular.json
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
}
}
NgRx State Management Overview
NgRx applies the Redux pattern to Angular for predictable global state management in complex applications.
[Action dispatched] → [Reducer updates state] → [Store updated]
↑ ↓
[Effect handles side effects] [Selector reads state]
Installation
ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/entity@latest # Collection management
ng add @ngrx/devtools@latest # Redux DevTools integration
Define Actions
// store/products/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../../models/product.model';
// Load product list
export const loadProducts = createAction('[Product List] Load Products');
export const loadProductsSuccess = createAction(
'[Product API] Load Products Success',
props<{ products: Product[] }>()
);
export const loadProductsFailure = createAction(
'[Product API] Load Products Failure',
props<{ error: string }>()
);
// Add product
export const addProduct = createAction(
'[Product Form] Add Product',
props<{ product: Omit<Product, 'id'> }>()
);
export const addProductSuccess = createAction(
'[Product API] Add Product Success',
props<{ product: Product }>()
);
// Delete product
export const deleteProduct = createAction(
'[Product List] Delete Product',
props<{ id: number }>()
);
Define Reducer
// store/products/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Product } from '../../models/product.model';
import * as ProductActions from './product.actions';
export interface ProductState extends EntityState<Product> {
isLoading: boolean;
error: string | null;
}
export const adapter: EntityAdapter<Product> = createEntityAdapter<Product>();
export const initialState: ProductState = adapter.getInitialState({
isLoading: false,
error: null
});
export const productReducer = createReducer(
initialState,
on(ProductActions.loadProducts, state => ({
...state,
isLoading: true,
error: null
})),
on(ProductActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, { ...state, isLoading: false })
),
on(ProductActions.loadProductsFailure, (state, { error }) => ({
...state,
isLoading: false,
error
})),
on(ProductActions.addProductSuccess, (state, { product }) =>
adapter.addOne(product, state)
),
on(ProductActions.deleteProduct, (state, { id }) =>
adapter.removeOne(id, state)
)
);
// Selector helpers
export const { selectAll, selectIds, selectEntities, selectTotal } =
adapter.getSelectors();
Define Effects (Side Effects)
// store/products/product.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ProductService } from '../../services/product.service';
import * as ProductActions from './product.actions';
@Injectable()
export class ProductEffects {
private actions$ = inject(Actions);
private productService = inject(ProductService);
loadProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.loadProducts),
switchMap(() =>
this.productService.getProducts().pipe(
map(products => ProductActions.loadProductsSuccess({ products })),
catchError(error =>
of(ProductActions.loadProductsFailure({ error: error.message }))
)
)
)
)
);
addProduct$ = createEffect(() =>
this.actions$.pipe(
ofType(ProductActions.addProduct),
switchMap(({ product }) =>
this.productService.createProduct(product).pipe(
map(newProduct => ProductActions.addProductSuccess({ product: newProduct })),
catchError(error =>
of(ProductActions.loadProductsFailure({ error: error.message }))
)
)
)
)
);
}
Define Selectors
// store/products/product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ProductState, selectAll } from './product.reducer';
export const selectProductState = createFeatureSelector<ProductState>('products');
export const selectAllProducts = createSelector(selectProductState, selectAll);
export const selectProductsLoading = createSelector(
selectProductState,
state => state.isLoading
);
export const selectProductsError = createSelector(
selectProductState,
state => state.error
);
// Derived selector
export const selectExpensiveProducts = createSelector(
selectAllProducts,
products => products.filter(p => p.price > 100)
);
export const selectProductById = (id: number) => createSelector(
selectProductState,
state => state.entities[id]
);
Using the Store in Components
// components/product-list.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { AsyncPipe } from '@angular/common';
import * as ProductActions from '../store/products/product.actions';
import * as ProductSelectors from '../store/products/product.selectors';
@Component({
standalone: true,
imports: [AsyncPipe],
template: `
@if (isLoading$ | async) {
<div class="loading">Loading...</div>
}
@if (error$ | async; as error) {
<div class="error">{{ error }}</div>
}
<ul>
@for (product of products$ | async ?? []; track product.id) {
<li>
{{ product.name }}
<button (click)="delete(product.id)">Delete</button>
</li>
}
</ul>
<button (click)="load()">Refresh</button>
`
})
export class ProductListComponent implements OnInit {
private store = inject(Store);
products$ = this.store.select(ProductSelectors.selectAllProducts);
isLoading$ = this.store.select(ProductSelectors.selectProductsLoading);
error$ = this.store.select(ProductSelectors.selectProductsError);
ngOnInit() {
this.store.dispatch(ProductActions.loadProducts());
}
delete(id: number) {
this.store.dispatch(ProductActions.deleteProduct({ id }));
}
load() {
this.store.dispatch(ProductActions.loadProducts());
}
}
Performance Optimization Tips
1. Bundle Analysis and Optimization
# Analyze bundle size
ng build --stats-json
npx webpack-bundle-analyzer dist/my-app/browser/stats.json
# Source map explorer
npx source-map-explorer dist/my-app/browser/*.js
2. @defer for Deferred Loading
@Component({
template: `
<!-- Only load when element enters viewport -->
@defer (on viewport) {
<app-analytics-chart [data]="chartData" />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton" style="height: 300px; background: #eee;"></div>
} @loading (minimum 200ms) {
<div class="loading-spinner">Loading chart...</div>
} @error {
<p>Failed to load chart.</p>
}
<!-- Load immediately -->
@defer (on immediate) {
<app-heavy-table />
}
<!-- Load after timer -->
@defer (on timer(3s)) {
<app-recommendation />
}
`
})
export class DashboardComponent {
chartData = [];
}
3. Image Optimization
<!-- Use NgOptimizedImage (Angular 15+) -->
<img
ngSrc="https://example.com/hero.jpg"
width="800"
height="400"
priority <!-- Prioritize LCP image -->
alt="Hero image"
>
<!-- Automatically handles srcset, lazy loading, preload -->
Folder Structure Best Practices
src/app/
├── core/ ← Singleton services, guards, interceptors
│ ├── guards/
│ │ ├── auth.guard.ts
│ │ └── admin.guard.ts
│ ├── interceptors/
│ │ ├── auth.interceptor.ts
│ │ └── error.interceptor.ts
│ ├── services/
│ │ ├── auth.service.ts
│ │ └── logger.service.ts
│ └── core.providers.ts ← Bundled core providers
│
├── shared/ ← Reusable components, pipes, directives
│ ├── components/
│ │ ├── button/
│ │ ├── modal/
│ │ └── loading-spinner/
│ ├── directives/
│ │ └── highlight.directive.ts
│ ├── pipes/
│ │ └── format-date.pipe.ts
│ └── models/
│ ├── user.model.ts
│ └── product.model.ts
│
├── features/ ← Feature modules (each independent)
│ ├── auth/
│ │ ├── login/
│ │ ├── register/
│ │ └── auth.routes.ts
│ ├── products/
│ │ ├── product-list/
│ │ ├── product-detail/
│ │ ├── product.service.ts
│ │ └── products.routes.ts
│ └── admin/
│ └── admin.routes.ts
│
├── app.component.ts
├── app.config.ts
└── app.routes.ts
Common Error Patterns and Solutions
Error 1: ExpressionChangedAfterItHasBeenCheckedError
// Problem: changing state in ngAfterViewInit → change detection error
ngAfterViewInit() {
this.isVisible = true; // ❌ Error
}
// Solution 1: setTimeout
ngAfterViewInit() {
setTimeout(() => this.isVisible = true);
}
// Solution 2: Use Signal (no problem)
isVisible = signal(false);
ngAfterViewInit() {
this.isVisible.set(true); // ✅
}
Error 2: NullInjectorError
// Problem: Service not in providers
// NullInjectorError: No provider for UserService!
// Solution 1: Add providedIn: 'root'
@Injectable({ providedIn: 'root' })
export class UserService {}
// Solution 2: Add to component providers
@Component({
providers: [UserService]
})
Error 3: Memory Leaks
// Problem: Subscription never unsubscribed
ngOnInit() {
this.dataService.getData().subscribe(d => this.data = d); // Leak!
}
// Solution: takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
ngOnInit() {
this.dataService.getData().pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(d => this.data.set(d));
}
Error 4: Template Parse Errors
<!-- Problem: Wrong binding syntax -->
<div [style]="color: red"> ❌
<div [style.color]="'red'"> ✅
<div [ngStyle]="{ color: 'red' }"> ✅
Angular DevTools
Install Angular DevTools in Chrome/Firefox:
- Components tab: Inspect component tree, Inputs/Outputs
- Profiler tab: Measure change detection cycles, identify slow components
- Injector Tree: Visualize DI hierarchy
Deployment Optimization
# Production build (optimizations included by default)
ng build --configuration production
# SSR build (SEO optimization)
ng build --configuration production --ssr
# Review build output
ls dist/my-app/browser/
# main.abc123.js ← Cache-busting hash
# styles.def456.css
# index.html
nginx Deployment Config (SPA Routing)
# /etc/nginx/sites-available/my-angular-app
server {
listen 80;
server_name myapp.com;
root /var/www/my-angular-app/browser;
index index.html;
# Cache static files (files with hash)
location ~* \.(js|css|png|jpg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Angular SPA routing — serve all requests via index.html
location / {
try_files $uri $uri/ /index.html;
}
}
Unit Testing — Migrating to Jest
Angular's default testing is Karma + Jasmine, but Jest is faster and more convenient.
# Migrate to Jest
ng add jest-preset-angular
# Or Vitest
// Basic component test
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent] // Standalone components go in imports
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should have an initial count of 0', () => {
expect(component.count()).toBe(0);
});
it('should increment count on increment click', () => {
const button = fixture.nativeElement.querySelector('[data-testid="increment"]');
button.click();
fixture.detectChanges();
expect(component.count()).toBe(1);
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
});
Summary — Angular 19 Core Checklist
| Item | Recommended Approach |
|---|---|
| Components | Standalone, OnPush |
| State Management | Signals (local), NgRx (global) |
| Data Binding | input()/output()/model() |
| Async | RxJS + AsyncPipe or toSignal() |
| HTTP | HttpClient + Interceptors |
| Routing | loadComponent + Guards |
| DI | inject() function |
| Testing | Jest + Angular Testing Library |
| Performance | @defer, OnPush, Lazy Loading |
| Folder Structure | core/shared/features |