Skip to main content
Advertisement

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:

  1. Components tab: Inspect component tree, Inputs/Outputs
  2. Profiler tab: Measure change detection cycles, identify slow components
  3. 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

ItemRecommended Approach
ComponentsStandalone, OnPush
State ManagementSignals (local), NgRx (global)
Data Bindinginput()/output()/model()
AsyncRxJS + AsyncPipe or toSignal()
HTTPHttpClient + Interceptors
RoutingloadComponent + Guards
DIinject() function
TestingJest + Angular Testing Library
Performance@defer, OnPush, Lazy Loading
Folder Structurecore/shared/features
Advertisement