Phase 8 — HTTP Client & APIs

Chapter 1 — What is HTTP and Why Do You Need It?


1.1 — Your App Needs to Talk to the Outside World

Everything you have built so far has used data that was hardcoded directly in your components and services. The product list was a plain array in a TypeScript file. The blog posts were objects you typed manually. The user was a mock object you created yourself.

Real applications do not work this way. Real applications get their data from a server. When a user logs in, their credentials are verified by a server. When they browse products, those products come from a database through a server. When they place an order, that order is saved on a server. When they update their profile, that update is sent to a server.

The way your Angular app communicates with a server is through HTTP — Hypertext Transfer Protocol. It is the same protocol your browser uses to load web pages. Your Angular app sends HTTP requests to an API (a server endpoint), and the server sends back HTTP responses with data.

This phase covers how Angular handles all of this.


1.2 — What is a REST API?

When Angular talks to a server, it almost always talks to a REST API. REST (Representational State Transfer) is a set of conventions for how URLs and HTTP methods should be organized.

The conventions work like this. Each URL represents a resource — a type of data. The HTTP method tells the server what to do with that resource:

GET    /api/products         → fetch all products
GET    /api/products/42      → fetch one product with id 42
POST   /api/products         → create a new product
PUT    /api/products/42      → replace the entire product with id 42
PATCH  /api/products/42      → update specific fields of product with id 42
DELETE /api/products/42      → delete the product with id 42

When you fetch data, you use GET. When you create something new, you use POST. When you update something, you use PUT or PATCH. When you delete something, you use DELETE.

The server responds with data in JSON format — which is perfect for JavaScript and TypeScript because JSON maps directly to objects and arrays.


1.3 — Angular's HttpClient

Angular provides a built-in service called HttpClient for making HTTP requests. It lives in @angular/common/http. It is the official, recommended way to communicate with APIs from Angular.

HttpClient returns Observables from every method. An Observable is a stream of data over time. When you make an HTTP request, the Observable emits once when the response arrives, and then completes. We go deep into Observables in Phase 9. For now just know: you call .subscribe() on an Observable to actually make the request and receive the response.


Chapter 2 — Setting Up HttpClient


2.1 — Providing HttpClient in Your App

In Angular, you need to make HttpClient available before you can use it. You do this in app.config.ts with provideHttpClient:

src/app/app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient()    // ← add this one line
  ]
};

That single line makes HttpClient available for injection in every service and component in your entire application.


2.2 — A Note About the API We Will Use

Throughout this phase, we will use a real public API called JSONPlaceholder at https://jsonplaceholder.typicode.com. It is a free fake REST API for testing and learning. It has endpoints for posts, users, comments, todos, and albums — all returning real-looking JSON data.

This means you do not need to build or run a backend server. The requests go to the real internet and come back with real data.


Chapter 3 — Making HTTP Requests


3.1 — Your First GET Request

Let's build a service that fetches a list of posts from the API.

Generate the service:

ng g s services/posts --skip-tests

src/app/services/posts.ts:

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

@Injectable({
  providedIn: 'root'
})
export class Posts {

  private http = inject(HttpClient);
  private apiUrl = 'https://jsonplaceholder.typicode.com';

  // GET all posts
  getAllPosts(): Observable<Post[]> {
    return this.http.get<Post[]>(`${this.apiUrl}/posts`);
  }

  // GET a single post by ID
  getPostById(id: number): Observable<Post> {
    return this.http.get<Post>(`${this.apiUrl}/posts/${id}`);
  }
}

this.http.get<Post[]>(url) — the <Post[]> is a TypeScript generic. You are telling Angular "I expect the response to be an array of Post objects." TypeScript then knows the shape of the data when you use it.

The method returns Observable<Post[]> — a stream that will eventually emit the array of posts when the server responds.

Nothing actually happens yet. The HTTP request is not sent until something subscribes to the Observable.


3.2 — Using the Service in a Component

Now let's create a component that uses this service:

src/app/pages/posts/posts.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { Posts as PostsService, Post } from '../../services/posts';

@Component({
  selector: 'app-posts',
  imports: [],
  templateUrl: './posts.html',
  styleUrl: './posts.css'
})
export class PostsPage implements OnInit {

  private postsService = inject(PostsService);

  posts = signal<Post[]>([]);
  isLoading = signal<boolean>(false);
  error = signal<string>('');

  ngOnInit(): void {
    this.loadPosts();
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.error.set('');

    this.postsService.getAllPosts().subscribe({
      next: (data) => {
        this.posts.set(data);
        this.isLoading.set(false);
      },
      error: (err) => {
        this.error.set('Failed to load posts. Please try again.');
        this.isLoading.set(false);
        console.error('HTTP Error:', err);
      }
    });
  }
}

The .subscribe() call is where the magic happens. This is what actually fires the HTTP request. You pass an object with two callbacks:

next — runs when the response arrives successfully. The data parameter contains the parsed JSON response, already typed as Post[] because of the generic you specified.

error — runs when something goes wrong — the server returned an error status code, the network is down, or any other HTTP error. The err parameter contains information about what went wrong.

There is also an optional third callback:

complete — runs after next, when the Observable finishes emitting. For HTTP requests this is not commonly used because HTTP responses only emit once.

src/app/pages/posts/posts.html:

<div class="posts-page">
  <h1>Latest Posts</h1>

  @if (isLoading()) {
    <div class="loading">
      <div class="spinner"></div>
      <p>Loading posts...</p>
    </div>
  }

  @if (error()) {
    <div class="error-banner">
      <p>{{ error() }}</p>
      <button (click)="loadPosts()">Try Again</button>
    </div>
  }

  @if (!isLoading() && !error()) {
    <div class="posts-grid">
      @for (post of posts(); track post.id) {
        <div class="post-card">
          <span class="post-id">#{{ post.id }}</span>
          <h3>{{ post.title }}</h3>
          <p>{{ post.body }}</p>
        </div>
      }
    </div>
  }
</div>

src/app/pages/posts/posts.css:

.posts-page {
  max-width: 900px;
  margin: 0 auto;
  padding: 48px 24px;
}

h1 {
  font-size: 36px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 32px;
}

.loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 64px;
  gap: 16px;
  color: #888;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #e0e0e0;
  border-top-color: #0070f3;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.error-banner {
  background: #fef2f2;
  border: 1px solid #fecaca;
  border-radius: 12px;
  padding: 24px;
  text-align: center;
  color: #dc2626;
}

.error-banner button {
  margin-top: 12px;
  background: #dc2626;
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
}

.posts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 20px;
}

.post-card {
  background: white;
  padding: 22px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.07);
  transition: box-shadow 0.2s;
}

.post-card:hover {
  box-shadow: 0 4px 20px rgba(0,0,0,0.12);
}

.post-id {
  font-size: 12px;
  font-weight: 700;
  color: #0070f3;
  text-transform: uppercase;
  letter-spacing: 1px;
}

.post-card h3 {
  font-size: 16px;
  font-weight: 600;
  color: #1a1a2e;
  margin: 8px 0;
  text-transform: capitalize;
}

.post-card p {
  font-size: 14px;
  color: #666;
  line-height: 1.6;
}

3.3 — The Three States of Every HTTP Call

Every HTTP request has three possible states and your UI should handle all three:

Loading — the request has been sent but no response yet. Show a spinner or skeleton screen. Disable any action buttons.

Success — the server responded with data. Show the data.

Error — something went wrong. Show a meaningful error message. Provide a way to retry.

This pattern of three signals — isLoading, data, and error — is something you will use in almost every component that makes an HTTP call. It becomes second nature.


3.4 — POST — Creating New Data

Sending a POST request creates a new resource on the server. You pass the data to create as the second argument to http.post():

export interface CreatePostDto {
  title: string;
  body: string;
  userId: number;
}

@Injectable({
  providedIn: 'root'
})
export class Posts {

  private http = inject(HttpClient);
  private apiUrl = 'https://jsonplaceholder.typicode.com';

  createPost(data: CreatePostDto): Observable<Post> {
    return this.http.post<Post>(`${this.apiUrl}/posts`, data);
  }
}

Using it in a component:

newPostTitle: string = '';
newPostBody: string = '';
isCreating = signal<boolean>(false);

createPost(): void {
  this.isCreating.set(true);

  const newPost: CreatePostDto = {
    title: this.newPostTitle,
    body: this.newPostBody,
    userId: 1
  };

  this.postsService.createPost(newPost).subscribe({
    next: (createdPost) => {
      console.log('Created post:', createdPost);
      // The API returns the created object with a new ID
      this.posts.update(current => [createdPost, ...current]);
      this.newPostTitle = '';
      this.newPostBody = '';
      this.isCreating.set(false);
    },
    error: (err) => {
      console.error('Failed to create post:', err);
      this.isCreating.set(false);
    }
  });
}

http.post() takes the URL and the request body. The body is automatically serialized to JSON. Angular automatically adds the Content-Type: application/json header.


3.5 — PUT — Replacing an Entire Resource

PUT replaces the entire resource with the new data you send:

updatePost(id: number, data: Post): Observable<Post> {
  return this.http.put<Post>(`${this.apiUrl}/posts/${id}`, data);
}

3.6 — PATCH — Updating Specific Fields

PATCH sends only the fields you want to change, not the entire object:

patchPost(id: number, changes: Partial<Post>): Observable<Post> {
  return this.http.patch<Post>(`${this.apiUrl}/posts/${id}`, changes);
}

Partial<Post> is a TypeScript utility type that makes all fields of Post optional. This is perfect for PATCH because you might only be sending { title: 'New Title' } without the other fields.


3.7 — DELETE — Removing a Resource

DELETE removes a resource. The server usually returns an empty object or no body:

deletePost(id: number): Observable<void> {
  return this.http.delete<void>(`${this.apiUrl}/posts/${id}`);
}

Using it:

deletePost(postId: number): void {
  this.postsService.deletePost(postId).subscribe({
    next: () => {
      // Remove from local array after successful deletion
      this.posts.update(current => current.filter(p => p.id !== postId));
      console.log('Post deleted successfully');
    },
    error: (err) => {
      console.error('Failed to delete:', err);
    }
  });
}

Chapter 4 — Request Options


4.1 — HTTP Headers

Sometimes you need to send extra information with your request in the form of headers. The most common is an Authorization header for authenticated requests, or a custom Content-Type header.

import { HttpClient, HttpHeaders } from '@angular/common/http';

getSecureData(): Observable<any> {
  const headers = new HttpHeaders({
    'Authorization': 'Bearer your-jwt-token-here',
    'X-Custom-Header': 'some-value'
  });

  return this.http.get<any>(`${this.apiUrl}/secure-endpoint`, { headers });
}

HttpHeaders is immutable — once created, you cannot change it. But you can create a new one from an existing one:

const baseHeaders = new HttpHeaders({ 'Content-Type': 'application/json' });
const authHeaders = baseHeaders.set('Authorization', 'Bearer token123');
// baseHeaders still does NOT have the Authorization header
// authHeaders is a new object that has both headers

4.2 — Query Parameters

For adding query parameters programmatically, use HttpParams:

import { HttpClient, HttpParams } from '@angular/common/http';

searchPosts(query: string, page: number = 1, limit: number = 10): Observable<Post[]> {
  const params = new HttpParams()
    .set('q', query)
    .set('_page', page.toString())
    .set('_limit', limit.toString());

  return this.http.get<Post[]>(`${this.apiUrl}/posts`, { params });
  // Makes request to: /posts?q=angular&_page=1&_limit=10
}

Like HttpHeaders, HttpParams is immutable. Each .set() returns a new HttpParams object.


4.3 — Observe the Full Response

By default, Angular gives you just the response body — the actual data. But sometimes you need the full HTTP response including the status code and response headers. Use observe: 'response':

import { HttpClient, HttpResponse } from '@angular/common/http';

getPostWithHeaders(id: number): Observable<HttpResponse<Post>> {
  return this.http.get<Post>(`${this.apiUrl}/posts/${id}`, {
    observe: 'response'    // give me the full response object
  });
}

Using it:

this.postsService.getPostWithHeaders(1).subscribe({
  next: (response) => {
    console.log('Status code:', response.status);      // 200
    console.log('Status text:', response.statusText);  // OK
    console.log('Headers:', response.headers);
    console.log('Body (actual data):', response.body); // the Post object
  }
});

4.4 — Response Types

By default Angular expects JSON responses. For other formats, use the responseType option:

// Download a file as blob
downloadFile(fileName: string): Observable<Blob> {
  return this.http.get(`${this.apiUrl}/files/${fileName}`, {
    responseType: 'blob'
  });
}

// Get plain text response
getTextContent(): Observable<string> {
  return this.http.get(`${this.apiUrl}/content`, {
    responseType: 'text'
  });
}

Chapter 5 — Error Handling


5.1 — Types of HTTP Errors

When an HTTP request fails, Angular gives you an HttpErrorResponse object. Errors come in two categories:

Client-side / network errors — the request never reached the server. The user is offline, the URL is wrong, a network timeout occurred. These errors have error.status === 0.

Server-side errors — the server received the request but responded with an error status code. 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error. These have a non-zero status code.


5.2 — Handling Errors in the Component

The simplest way to handle errors is in the component's subscribe callback:

import { HttpErrorResponse } from '@angular/common/http';

loadPosts(): void {
  this.isLoading.set(true);
  this.error.set('');

  this.postsService.getAllPosts().subscribe({
    next: (data) => {
      this.posts.set(data);
      this.isLoading.set(false);
    },
    error: (err: HttpErrorResponse) => {
      this.isLoading.set(false);

      if (err.status === 0) {
        // Network error — no connection to server
        this.error.set('Network error. Please check your internet connection.');
      } else if (err.status === 404) {
        this.error.set('The requested data was not found.');
      } else if (err.status === 401) {
        this.error.set('You are not authorized. Please log in again.');
      } else if (err.status === 500) {
        this.error.set('Server error. Please try again later.');
      } else {
        this.error.set(`Something went wrong (Error ${err.status}).`);
      }

      console.error('HTTP Error Details:', err);
    }
  });
}

5.3 — Handling Errors in the Service with catchError

For reusable error handling that you want to centralize in the service rather than handle in every component, use the catchError RxJS operator:

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class Posts {

  private http = inject(HttpClient);
  private apiUrl = 'https://jsonplaceholder.typicode.com';

  getAllPosts(): Observable<Post[]> {
    return this.http.get<Post[]>(`${this.apiUrl}/posts`).pipe(
      retry(1),           // retry the request once before giving up
      catchError(this.handleError)
    );
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    let errorMessage = '';

    if (error.status === 0) {
      errorMessage = 'Network error. Please check your connection.';
    } else {
      errorMessage = `Server error ${error.status}: ${error.message}`;
    }

    console.error(errorMessage);
    return throwError(() => new Error(errorMessage));
  }
}

.pipe() is how you chain RxJS operators on an Observable. Think of it as a pipeline — the HTTP response flows through each operator in order.

retry(1) automatically retries the failed request one time before passing the error down. Useful for transient network issues.

catchError catches any error in the pipeline, lets you handle it, and then decides what to return. throwError(() => new Error(message)) re-throws the error so the component's error callback still fires.

We cover RxJS operators thoroughly in Phase 9.


Chapter 6 — HTTP Interceptors


6.1 — What is an Interceptor?

An interceptor is a piece of code that sits between your application and every HTTP request and response. It intercepts every request going out and every response coming in.

This is incredibly powerful for cross-cutting concerns — things that need to happen for every single HTTP call without you having to add code to every single service:

  • Adding an auth token to every request automatically
  • Showing a global loading indicator when any request is in flight
  • Logging every request and response for debugging
  • Handling 401 errors globally by redirecting to login
  • Retrying failed requests automatically
  • Caching responses

Without interceptors, you would have to add the auth token manually in every service method. With an interceptor, you write it once and it applies everywhere automatically.


6.2 — Creating an Auth Token Interceptor

Generate an interceptor:

ng generate interceptor interceptors/auth-token --skip-tests

src/app/interceptors/auth-token.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Auth } from '../services/auth';

export const authTokenInterceptor: HttpInterceptorFn = (request, next) => {
  const authService = inject(Auth);

  // Get the auth token from the auth service
  const token = authService.getToken();

  if (token) {
    // Clone the request and add the Authorization header
    const authRequest = request.clone({
      headers: request.headers.set('Authorization', `Bearer ${token}`)
    });

    return next(authRequest);  // pass the modified request along
  }

  return next(request);  // no token — pass original request unchanged
};

An interceptor is a function that receives the outgoing request and a next function. You call next(request) to pass the request to the next interceptor or to actually send it to the server.

The most important thing here is request.clone(). HTTP requests are immutable in Angular — you cannot modify them directly. You must create a modified copy using .clone(). Pass an object with the properties you want to change.


6.3 — Creating a Loading Interceptor

This interceptor increments a counter when a request starts and decrements it when it finishes. Any component can read this counter to know if any HTTP call is in progress:

First, create a loading service:

src/app/services/loading.ts:

import { Injectable, signal, computed } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class Loading {

  private requestCount = signal<number>(0);

  readonly isLoading = computed(() => this.requestCount() > 0);

  increment(): void {
    this.requestCount.update(count => count + 1);
  }

  decrement(): void {
    this.requestCount.update(count => Math.max(0, count - 1));
  }
}

Now the interceptor:

src/app/interceptors/loading.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { Loading } from '../services/loading';

export const loadingInterceptor: HttpInterceptorFn = (request, next) => {
  const loadingService = inject(Loading);

  loadingService.increment();   // request started

  return next(request).pipe(
    finalize(() => {
      loadingService.decrement();  // request finished (success OR error)
    })
  );
};

finalize is an RxJS operator that runs a callback when the Observable completes or errors. It is the perfect place to turn off the loading state because it runs regardless of whether the request succeeded or failed.


6.4 — Creating an Error Interceptor

This interceptor handles common HTTP error scenarios globally:

src/app/interceptors/error.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 = (request, next) => {
  const router = inject(Router);

  return next(request).pipe(
    catchError((error: HttpErrorResponse) => {

      if (error.status === 401) {
        // Token expired or invalid — send user to login
        console.warn('Unauthorized. Redirecting to login.');
        router.navigate(['/login']);
      }

      if (error.status === 403) {
        console.warn('Forbidden. You do not have permission for this action.');
      }

      if (error.status === 0) {
        console.error('Network error. No connection to server.');
      }

      if (error.status >= 500) {
        console.error('Server error:', error.status);
      }

      return throwError(() => error);  // re-throw so components can still handle it
    })
  );
};

6.5 — Registering Interceptors

Interceptors are registered in app.config.ts using withInterceptors:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authTokenInterceptor } from './interceptors/auth-token';
import { loadingInterceptor } from './interceptors/loading';
import { errorInterceptor } from './interceptors/error';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([
        authTokenInterceptor,   // runs first — adds auth token
        loadingInterceptor,     // runs second — tracks loading state
        errorInterceptor        // runs third — handles errors globally
      ])
    )
  ]
};

Interceptors run in the order they are listed for outgoing requests, and in reverse order for incoming responses.


6.6 — Using the Loading Service in a Component

Now any component can show a global loading indicator by reading the Loading service:

import { Component, inject } from '@angular/core';
import { Loading } from './services/loading';

@Component({
  selector: 'app-root',
  imports: [],
  template: `
    @if (loadingService.isLoading()) {
      <div class="global-loader">
        <div class="loader-bar"></div>
      </div>
    }
    <router-outlet></router-outlet>
  `
})
export class App {
  loadingService = inject(Loading);
}

Every single HTTP request across your entire app automatically triggers this loader. No code changes needed in any service.


Chapter 7 — Environment Variables


7.1 — Why Environment Variables?

Your API URL is different depending on where your app is running:

During development on your laptop, the API might be at http://localhost:3000/api. During staging, it might be at https://staging-api.myapp.com/api. In production, it might be at https://api.myapp.com/api.

You do not want to manually change the URL every time you deploy. Environment variables let you define different values for different environments and Angular automatically uses the right one.


7.2 — Creating Environment Files

Angular does not create environment files by default in new projects. You create them manually.

Create this folder and two files:

src/environments/
├── environment.ts        ← used during development (ng serve)
└── environment.prod.ts   ← used during production (ng build)

src/environments/environment.ts:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api',
  appName: 'MyApp (Dev)'
};

src/environments/environment.prod.ts:

export const environment = {
  production: true,
  apiUrl: 'https://api.myapp.com/api',
  appName: 'MyApp'
};

7.3 — Telling Angular to Swap Files on Build

You need to configure angular.json so Angular knows to replace environment.ts with environment.prod.ts when building for production. Find the configurationsproduction section and add:

"configurations": {
  "production": {
    "fileReplacements": [
      {
        "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.prod.ts"
      }
    ]
  }
}

Now when you run ng build --configuration=production, Angular automatically swaps the files.


7.4 — Using Environment Variables in Services

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class Posts {

  private http = inject(HttpClient);
  private apiUrl = environment.apiUrl;   // ← reads from environment file

  getAllPosts(): Observable<Post[]> {
    return this.http.get<Post[]>(`${this.apiUrl}/posts`);
  }
}

When you run ng serve, environment.apiUrl is http://localhost:3000/api. When you run ng build --configuration=production, environment.apiUrl is https://api.myapp.com/api. You never touch the service code.


Chapter 8 — Organizing API Services


8.1 — One Service Per Resource

The best practice is to create one service per API resource. Each service handles all the HTTP calls related to that resource:

src/app/services/
├── posts.ts         ← all post-related API calls
├── users.ts         ← all user-related API calls
├── auth.ts          ← login, logout, token management
├── comments.ts      ← all comment-related API calls
└── products.ts      ← all product-related API calls

This keeps each service focused and easy to understand.


8.2 — A Complete, Well-Organized API Service

Here is what a complete, production-quality service looks like:

src/app/services/users.ts:

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { environment } from '../../environments/environment';

export interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  website: string;
  company: {
    name: string;
    catchPhrase: string;
  };
  address: {
    city: string;
    street: string;
  };
}

export interface CreateUserDto {
  name: string;
  email: string;
  phone: string;
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
  phone?: string;
}

@Injectable({
  providedIn: 'root'
})
export class Users {

  private http = inject(HttpClient);
  private baseUrl = `${environment.apiUrl}/users`;

  getAll(): Observable<User[]> {
    return this.http.get<User[]>(this.baseUrl).pipe(
      catchError(this.handleError)
    );
  }

  getById(id: number): Observable<User> {
    return this.http.get<User>(`${this.baseUrl}/${id}`).pipe(
      catchError(this.handleError)
    );
  }

  search(name: string): Observable<User[]> {
    const params = new HttpParams().set('name_like', name);
    return this.http.get<User[]>(this.baseUrl, { params }).pipe(
      catchError(this.handleError)
    );
  }

  create(userData: CreateUserDto): Observable<User> {
    return this.http.post<User>(this.baseUrl, userData).pipe(
      catchError(this.handleError)
    );
  }

  update(id: number, changes: UpdateUserDto): Observable<User> {
    return this.http.patch<User>(`${this.baseUrl}/${id}`, changes).pipe(
      catchError(this.handleError)
    );
  }

  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
      catchError(this.handleError)
    );
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    const message = error.status === 0
      ? 'Network error. Please check your connection.'
      : `Server error ${error.status}: ${error.message}`;

    return throwError(() => new Error(message));
  }
}

This service is clean and predictable. Each method does exactly one thing. All methods are typed. Error handling is centralized. Any component that injects this service gets a consistent, reliable interface.


Chapter 9 — Putting It All Together — A Complete App


Let's build a complete User Management Dashboard that demonstrates everything from this phase — GET, POST, PATCH, DELETE, loading states, error handling, interceptors, and environment configuration.


Project Setup

ng new user-dashboard --style=css
cd user-dashboard

ng g s services/users --skip-tests
ng g s services/loading --skip-tests
ng g s services/notifications --skip-tests
ng g interceptor interceptors/loading --skip-tests
ng g interceptor interceptors/error --skip-tests
ng g c pages/users/user-list --skip-tests
ng g c pages/users/user-detail --skip-tests
ng g c components/global-loader --skip-tests
ng g c components/toast --skip-tests

App Config with HttpClient and Interceptors

src/app/app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { loadingInterceptor } from './interceptors/loading';
import { errorInterceptor } from './interceptors/error';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([loadingInterceptor, errorInterceptor])
    )
  ]
};

Environment Files

src/environments/environment.ts:

export const environment = {
  production: false,
  apiUrl: 'https://jsonplaceholder.typicode.com'
};

Loading Service

src/app/services/loading.ts:

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Loading {
  private count = signal(0);
  readonly isLoading = computed(() => this.count() > 0);
  increment() { this.count.update(n => n + 1); }
  decrement() { this.count.update(n => Math.max(0, n - 1)); }
}

Loading Interceptor

src/app/interceptors/loading.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { Loading } from '../services/loading';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(Loading);
  loading.increment();
  return next(req).pipe(finalize(() => loading.decrement()));
};

Error Interceptor

src/app/interceptors/error.ts:

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Notifications } from '../services/notifications';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const notifications = inject(Notifications);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 0) {
        notifications.error('Network error. Please check your connection.');
      } else if (error.status >= 500) {
        notifications.error(`Server error ${error.status}. Please try again later.`);
      }
      return throwError(() => error);
    })
  );
};

Notifications Service

src/app/services/notifications.ts:

import { Injectable, signal } from '@angular/core';

export interface ToastMessage {
  id: number;
  text: string;
  type: 'success' | 'error' | 'info';
}

@Injectable({ providedIn: 'root' })
export class Notifications {

  private list = signal<ToastMessage[]>([]);
  readonly messages = this.list.asReadonly();
  private nextId = 1;

  show(text: string, type: ToastMessage['type'] = 'info'): void {
    const id = this.nextId++;
    this.list.update(msgs => [...msgs, { id, text, type }]);
    setTimeout(() => this.remove(id), 3500);
  }

  success(text: string) { this.show(text, 'success'); }
  error(text: string) { this.show(text, 'error'); }
  info(text: string) { this.show(text, 'info'); }

  remove(id: number): void {
    this.list.update(msgs => msgs.filter(m => m.id !== id));
  }
}

Users Service

src/app/services/users.ts:

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';

export interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
  website: string;
  company: { name: string };
  address: { city: string };
}

@Injectable({ providedIn: 'root' })
export class Users {

  private http = inject(HttpClient);
  private url = `${environment.apiUrl}/users`;

  getAll(): Observable<User[]> {
    return this.http.get<User[]>(this.url);
  }

  getById(id: number): Observable<User> {
    return this.http.get<User>(`${this.url}/${id}`);
  }

  create(data: Partial<User>): Observable<User> {
    return this.http.post<User>(this.url, data);
  }

  update(id: number, data: Partial<User>): Observable<User> {
    return this.http.patch<User>(`${this.url}/${id}`, data);
  }

  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.url}/${id}`);
  }
}

Toast Component

src/app/components/toast/toast.ts:

import { Component, inject } from '@angular/core';
import { NgClass } from '@angular/common';
import { Notifications } from '../../services/notifications';

@Component({
  selector: 'app-toast',
  imports: [NgClass],
  template: `
    <div class="toast-container">
      @for (msg of notifications.messages(); track msg.id) {
        <div class="toast" [ngClass]="'toast-' + msg.type">
          {{ msg.text }}
          <button (click)="notifications.remove(msg.id)">×</button>
        </div>
      }
    </div>
  `,
  styles: [`
    .toast-container {
      position: fixed; top: 20px; right: 20px;
      z-index: 9999; display: flex; flex-direction: column; gap: 10px;
    }
    .toast {
      display: flex; align-items: center; justify-content: space-between;
      padding: 14px 18px; border-radius: 8px; font-size: 14px; font-weight: 500;
      min-width: 280px; box-shadow: 0 4px 16px rgba(0,0,0,0.12);
      animation: slideIn 0.3s ease;
    }
    @keyframes slideIn {
      from { transform: translateX(100%); opacity: 0; }
      to { transform: translateX(0); opacity: 1; }
    }
    .toast-success { background: #d4edda; color: #155724; border-left: 4px solid #28a745; }
    .toast-error { background: #f8d7da; color: #721c24; border-left: 4px solid #dc3545; }
    .toast-info { background: #d1ecf1; color: #0c5460; border-left: 4px solid #17a2b8; }
    button {
      background: none; border: none; font-size: 18px;
      cursor: pointer; color: inherit; margin-left: 12px; opacity: 0.7;
    }
    button:hover { opacity: 1; }
  `]
})
export class Toast {
  notifications = inject(Notifications);
}

Global Loader Component

src/app/components/global-loader/global-loader.ts:

import { Component, inject } from '@angular/core';
import { Loading } from '../../services/loading';

@Component({
  selector: 'app-global-loader',
  imports: [],
  template: `
    @if (loading.isLoading()) {
      <div class="loader-bar"></div>
    }
  `,
  styles: [`
    .loader-bar {
      position: fixed; top: 0; left: 0; right: 0; height: 3px;
      background: linear-gradient(90deg, #0070f3, #64ffda);
      z-index: 9999;
      animation: load 1.2s ease-in-out infinite;
    }
    @keyframes load {
      0% { transform: translateX(-100%); }
      100% { transform: translateX(100%); }
    }
  `]
})
export class GlobalLoader {
  loading = inject(Loading);
}

User List Page

src/app/pages/users/user-list/user-list.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { Users, User } from '../../../services/users';
import { Notifications } from '../../../services/notifications';

@Component({
  selector: 'app-user-list',
  imports: [ReactiveFormsModule],
  templateUrl: './user-list.html',
  styleUrl: './user-list.css'
})
export class UserList implements OnInit {

  private usersService = inject(Users);
  private notifications = inject(Notifications);
  private router = inject(Router);
  private fb = inject(FormBuilder);

  users = signal<User[]>([]);
  isLoading = signal(false);
  error = signal('');
  showCreateForm = signal(false);
  deletingId = signal<number | null>(null);

  createForm = this.fb.group({
    name: [''],
    email: [''],
    phone: ['']
  });

  ngOnInit(): void {
    this.loadUsers();
  }

  loadUsers(): void {
    this.isLoading.set(true);
    this.error.set('');

    this.usersService.getAll().subscribe({
      next: (data) => {
        this.users.set(data);
        this.isLoading.set(false);
      },
      error: (err) => {
        this.error.set('Failed to load users. Please try again.');
        this.isLoading.set(false);
      }
    });
  }

  viewUser(id: number): void {
    this.router.navigate(['/users', id]);
  }

  createUser(): void {
    const formValue = this.createForm.value;
    if (!formValue.name || !formValue.email) return;

    this.usersService.create(formValue).subscribe({
      next: (newUser) => {
        this.users.update(list => [...list, newUser]);
        this.showCreateForm.set(false);
        this.createForm.reset();
        this.notifications.success(`User "${newUser.name}" created successfully!`);
      },
      error: () => {
        this.notifications.error('Failed to create user.');
      }
    });
  }

  deleteUser(user: User, event: Event): void {
    event.stopPropagation();  // prevent triggering viewUser

    if (!confirm(`Delete user "${user.name}"?`)) return;

    this.deletingId.set(user.id);

    this.usersService.delete(user.id).subscribe({
      next: () => {
        this.users.update(list => list.filter(u => u.id !== user.id));
        this.deletingId.set(null);
        this.notifications.success(`User "${user.name}" deleted.`);
      },
      error: () => {
        this.deletingId.set(null);
        this.notifications.error('Failed to delete user.');
      }
    });
  }
}

src/app/pages/users/user-list/user-list.html:

<div class="user-list-page">
  <div class="page-header">
    <div>
      <h1>Users</h1>
      <p class="subtitle">{{ users().length }} total users</p>
    </div>
    <button class="btn-primary" (click)="showCreateForm.set(!showCreateForm())">
      {{ showCreateForm() ? 'Cancel' : '+ Add User' }}
    </button>
  </div>

  @if (showCreateForm()) {
    <div class="create-form-card" [formGroup]="createForm">
      <h3>Create New User</h3>
      <div class="form-row">
        <input type="text" formControlName="name" placeholder="Full Name">
        <input type="email" formControlName="email" placeholder="Email">
        <input type="tel" formControlName="phone" placeholder="Phone">
        <button class="btn-primary" (click)="createUser()">Create</button>
      </div>
    </div>
  }

  @if (isLoading()) {
    <div class="loading-state">
      <div class="spinner"></div>
      <p>Loading users...</p>
    </div>
  }

  @if (error()) {
    <div class="error-state">
      <p>{{ error() }}</p>
      <button (click)="loadUsers()">Try Again</button>
    </div>
  }

  @if (!isLoading() && !error()) {
    <div class="user-grid">
      @for (user of users(); track user.id) {
        <div class="user-card" (click)="viewUser(user.id)">
          <div class="user-avatar">{{ user.name[0] }}</div>
          <div class="user-info">
            <h3>{{ user.name }}</h3>
            <p class="email">{{ user.email }}</p>
            <p class="company">{{ user.company.name }}</p>
            <p class="city">📍 {{ user.address.city }}</p>
          </div>
          <button
            class="delete-btn"
            (click)="deleteUser(user, $event)"
            [disabled]="deletingId() === user.id">
            {{ deletingId() === user.id ? '...' : '🗑' }}
          </button>
        </div>
      }
    </div>
  }
</div>

src/app/pages/users/user-list/user-list.css:

.user-list-page {
  max-width: 1000px;
  margin: 0 auto;
  padding: 40px 24px;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 32px;
}

h1 {
  font-size: 32px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 4px;
}

.subtitle { color: #888; font-size: 14px; }

.btn-primary {
  background: #0070f3;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover { background: #005ac1; }

.create-form-card {
  background: white;
  padding: 24px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  margin-bottom: 28px;
  border: 2px solid #e8f4ff;
}

.create-form-card h3 {
  font-size: 16px;
  color: #1a1a2e;
  margin-bottom: 16px;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr auto;
  gap: 12px;
  align-items: center;
}

.form-row input {
  padding: 10px 14px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 14px;
}

.form-row input:focus { outline: none; border-color: #0070f3; }

.loading-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 64px;
  gap: 16px;
  color: #888;
}

.spinner {
  width: 40px; height: 40px;
  border: 3px solid #e0e0e0;
  border-top-color: #0070f3;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

.error-state {
  background: #fef2f2;
  border: 1px solid #fecaca;
  border-radius: 12px;
  padding: 24px;
  text-align: center;
  color: #dc2626;
}

.error-state button {
  margin-top: 12px;
  background: #dc2626;
  color: white;
  border: none;
  padding: 8px 20px;
  border-radius: 8px;
  cursor: pointer;
}

.user-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
}

.user-card {
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.07);
  cursor: pointer;
  transition: all 0.2s;
  display: flex;
  gap: 16px;
  align-items: flex-start;
  position: relative;
}

.user-card:hover {
  box-shadow: 0 6px 24px rgba(0,0,0,0.12);
  transform: translateY(-2px);
}

.user-avatar {
  width: 48px; height: 48px;
  background: linear-gradient(135deg, #0070f3, #64ffda);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 20px;
  font-weight: 700;
  flex-shrink: 0;
}

.user-info { flex: 1; min-width: 0; }

.user-info h3 {
  font-size: 15px;
  font-weight: 700;
  color: #1a1a2e;
  margin-bottom: 4px;
}

.email, .company, .city {
  font-size: 13px;
  color: #888;
  margin-bottom: 2px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.delete-btn {
  background: none;
  border: none;
  font-size: 16px;
  cursor: pointer;
  padding: 4px;
  opacity: 0.4;
  transition: opacity 0.2s;
}

.delete-btn:hover { opacity: 1; }
.delete-btn:disabled { cursor: not-allowed; }

User Detail Page

src/app/pages/users/user-detail/user-detail.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
import { Users, User } from '../../../services/users';
import { Notifications } from '../../../services/notifications';

@Component({
  selector: 'app-user-detail',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './user-detail.html',
  styleUrl: './user-detail.css'
})
export class UserDetail implements OnInit {

  private route = inject(ActivatedRoute);
  private usersService = inject(Users);
  private notifications = inject(Notifications);
  private fb = inject(FormBuilder);

  user = signal<User | null>(null);
  isLoading = signal(true);
  isEditing = signal(false);
  isSaving = signal(false);

  editForm = this.fb.group({
    name: [''],
    email: [''],
    phone: [''],
    website: ['']
  });

  ngOnInit(): void {
    const id = Number(this.route.snapshot.params['id']);
    this.loadUser(id);
  }

  loadUser(id: number): void {
    this.usersService.getById(id).subscribe({
      next: (data) => {
        this.user.set(data);
        this.editForm.patchValue({
          name: data.name,
          email: data.email,
          phone: data.phone,
          website: data.website
        });
        this.isLoading.set(false);
      },
      error: () => {
        this.isLoading.set(false);
        this.notifications.error('Failed to load user.');
      }
    });
  }

  startEditing(): void {
    this.isEditing.set(true);
  }

  cancelEditing(): void {
    const u = this.user();
    if (u) {
      this.editForm.patchValue({
        name: u.name, email: u.email,
        phone: u.phone, website: u.website
      });
    }
    this.isEditing.set(false);
  }

  saveChanges(): void {
    const userId = this.user()?.id;
    if (!userId) return;

    this.isSaving.set(true);

    this.usersService.update(userId, this.editForm.value).subscribe({
      next: (updated) => {
        this.user.set(updated);
        this.isEditing.set(false);
        this.isSaving.set(false);
        this.notifications.success('User updated successfully!');
      },
      error: () => {
        this.isSaving.set(false);
        this.notifications.error('Failed to update user.');
      }
    });
  }
}

src/app/pages/users/user-detail/user-detail.html:

<div class="user-detail">

  <a routerLink="/users" class="back-link">← Back to Users</a>

  @if (isLoading()) {
    <div class="loading">
      <div class="spinner"></div>
    </div>
  }

  @if (user()) {
    <div class="detail-card">

      <div class="detail-header">
        <div class="big-avatar">{{ user()!.name[0] }}</div>
        <div class="header-info">
          @if (isEditing()) {
            <input [formControl]="editForm.get('name')!" type="text" class="edit-input name-input">
          } @else {
            <h1>{{ user()!.name }}</h1>
          }
          <p class="company">{{ user()!.company.name }}</p>
        </div>
        <div class="header-actions">
          @if (isEditing()) {
            <button class="btn-save" (click)="saveChanges()" [disabled]="isSaving()">
              {{ isSaving() ? 'Saving...' : 'Save Changes' }}
            </button>
            <button class="btn-cancel" (click)="cancelEditing()">Cancel</button>
          } @else {
            <button class="btn-edit" (click)="startEditing()">Edit Profile</button>
          }
        </div>
      </div>

      <div class="detail-body" [formGroup]="editForm">
        <div class="info-grid">

          <div class="info-item">
            <label>Email</label>
            @if (isEditing()) {
              <input type="email" formControlName="email" class="edit-input">
            } @else {
              <p>{{ user()!.email }}</p>
            }
          </div>

          <div class="info-item">
            <label>Phone</label>
            @if (isEditing()) {
              <input type="tel" formControlName="phone" class="edit-input">
            } @else {
              <p>{{ user()!.phone }}</p>
            }
          </div>

          <div class="info-item">
            <label>Website</label>
            @if (isEditing()) {
              <input type="text" formControlName="website" class="edit-input">
            } @else {
              <p>{{ user()!.website }}</p>
            }
          </div>

          <div class="info-item">
            <label>City</label>
            <p>{{ user()!.address.city }}</p>
          </div>

        </div>
      </div>

    </div>
  }

</div>

src/app/pages/users/user-detail/user-detail.css:

.user-detail {
  max-width: 800px;
  margin: 0 auto;
  padding: 40px 24px;
}

.back-link {
  display: inline-block;
  color: #0070f3;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 28px;
}

.loading {
  display: flex;
  justify-content: center;
  padding: 64px;
}

.spinner {
  width: 40px; height: 40px;
  border: 3px solid #e0e0e0;
  border-top-color: #0070f3;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

.detail-card {
  background: white;
  border-radius: 16px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
  overflow: hidden;
}

.detail-header {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 28px 32px;
  background: linear-gradient(135deg, #0a192f, #112240);
}

.big-avatar {
  width: 72px; height: 72px;
  background: linear-gradient(135deg, #0070f3, #64ffda);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 32px;
  font-weight: 700;
  flex-shrink: 0;
}

.header-info { flex: 1; }

.header-info h1 {
  font-size: 24px;
  font-weight: 700;
  color: white;
  margin-bottom: 4px;
}

.company { color: #8892b0; font-size: 14px; }

.header-actions { display: flex; gap: 10px; }

.btn-edit {
  background: rgba(255,255,255,0.1);
  color: white;
  border: 1px solid rgba(255,255,255,0.2);
  padding: 8px 18px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.btn-edit:hover { background: rgba(255,255,255,0.2); }

.btn-save {
  background: #28a745;
  color: white;
  border: none;
  padding: 8px 18px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
}

.btn-save:disabled { background: #ccc; cursor: not-allowed; }

.btn-cancel {
  background: none;
  color: #ccc;
  border: 1px solid rgba(255,255,255,0.2);
  padding: 8px 18px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
}

.detail-body { padding: 32px; }

.info-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 24px;
}

.info-item label {
  display: block;
  font-size: 12px;
  font-weight: 700;
  color: #888;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: 6px;
}

.info-item p {
  font-size: 15px;
  color: #1a1a2e;
}

.edit-input {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 15px;
  box-sizing: border-box;
}

.edit-input:focus { outline: none; border-color: #0070f3; }

.name-input {
  background: rgba(255,255,255,0.1);
  border-color: rgba(255,255,255,0.3);
  color: white;
  font-size: 20px;
  font-weight: 700;
}

Routes

src/app/app.routes.ts:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'users',
    pathMatch: 'full'
  },
  {
    path: 'users',
    loadComponent: () => import('./pages/users/user-list/user-list').then(m => m.UserList)
  },
  {
    path: 'users/:id',
    loadComponent: () => import('./pages/users/user-detail/user-detail').then(m => m.UserDetail)
  }
];

App Component

src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { GlobalLoader } from './components/global-loader/global-loader';
import { Toast } from './components/toast/toast';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, GlobalLoader, Toast],
  template: `
    <app-global-loader></app-global-loader>
    <app-toast></app-toast>
    <router-outlet></router-outlet>
  `
})
export class App { }

src/styles.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

body {
  background: #f0f2f5;
  min-height: 100vh;
}

Run ng serve -o. You now have a complete User Management Dashboard that:

Fetches all 10 users from the JSONPlaceholder API on load. Shows a loading spinner and a top loading bar during every API call — powered by the loading interceptor. Displays users in a responsive card grid. Lets you view a user's full details on a separate page using route parameters. Lets you edit a user's details with a PATCH request. Lets you delete a user with a DELETE request. Shows success and error toast notifications for every action — with error toasts automatically shown by the error interceptor for server errors. Creates a new user with a POST request. All route code is lazy loaded.


Phase 8 — Complete Summary

Here is everything you learned in this phase.

What HTTP is — the protocol for communication between your Angular app and a server. REST APIs use GET, POST, PUT, PATCH, DELETE to create, read, update, and delete data.

Setting up HttpClientprovideHttpClient() in app.config.ts makes HttpClient available everywhere. Inject it with inject(HttpClient).

Making requestshttp.get<T>(url) fetches data. http.post<T>(url, body) creates. http.put<T>(url, body) replaces. http.patch<T>(url, changes) updates specific fields. http.delete<T>(url) removes. All return Observables. Nothing happens until you .subscribe().

The three states — every HTTP call has loading, success, and error states. Handle all three in your components with isLoading, data, and error signals.

Error handlingHttpErrorResponse in the subscribe error callback. status === 0 is a network error. Other status codes are server errors. catchError in the service for centralized handling.

HTTP HeadersHttpHeaders for adding headers to specific requests. Immutable — use .set() to create new headers.

Query paramsHttpParams for building query strings. Immutable — chain .set() calls.

Interceptors — functions that run for every HTTP request and response. HttpInterceptorFn is the modern function-based approach. Registered with withInterceptors([...]). Use for auth tokens, loading indicators, and global error handling. request.clone() to create a modified copy of a request.

Environment variablesenvironment.ts for development, environment.prod.ts for production. Configure file replacement in angular.json. Import environment directly in services.

Organizing services — one service per API resource. Define TypeScript interfaces for your data shapes. Centralize error handling in the service with catchError.


What's Next — Phase 9

In Phase 9 we go deep into RxJS and Observables — the reactive programming layer that powers everything HTTP in Angular:

What Observables are and why they exist. Creating Observables with of, from, interval, fromEvent. The difference between cold and hot Observables. Subjects and BehaviorSubjects for sharing state. The most important operators — map, filter, switchMap, mergeMap, debounceTime, distinctUntilChanged, catchError, forkJoin, combineLatest. Real patterns you will use constantly — search with debounce, chaining API calls, combining multiple requests.

Phase 7 — Forms

Chapter 1 — Why Forms Are a Big Deal


1.1 — Forms Are Everywhere

Every meaningful interaction a user has with a web application goes through a form. Login, registration, search, checkout, writing a post, updating a profile, placing an order, leaving a review — all of these are forms.

A form is not just an HTML <form> tag. A form is a system. It needs to collect user input, validate that input, show meaningful error messages at the right time, handle the loading state while submitting, and deal with success and failure responses.

Getting forms right makes the difference between an app that feels professional and one that feels broken. When a user fills out 10 fields and hits submit, sees a generic error message with no indication of what went wrong, and then realizes all their data was wiped — that is a bad form experience. Angular gives you the tools to make forms that are a pleasure to use.


1.2 — Two Approaches to Forms in Angular

Angular provides two completely different ways to build forms. They are not variations of the same thing — they are genuinely different approaches with different strengths.

Template-Driven Forms — You describe the form almost entirely in the HTML template using directives. Angular reads your template and builds the form model behind the scenes for you. This approach is simpler and faster to set up for basic forms. Less TypeScript code. The logic lives in the template.

Reactive Forms — You build the form model explicitly in TypeScript. The template then binds to that model. This approach is more powerful, more predictable, easier to test, and better for complex forms. More TypeScript code, but more control.

The rule of thumb is:

Use Template-Driven Forms for simple forms — a contact form, a newsletter signup, a basic login with two fields.

Use Reactive Forms for anything complex — multi-step forms, forms with dynamic fields, forms that depend on API data, forms that need complex validation.

In real applications, you will mostly use Reactive Forms. But understanding both is important because you will encounter both in existing codebases.


Chapter 2 — Template-Driven Forms


2.1 — Setting Up FormsModule

Template-driven forms require FormsModule. Import it directly in any component that uses template-driven forms:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

}

2.2 — Your First Template-Driven Form

Let's build a complete contact form step by step.

src/app/contact/contact.ts:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

  name: string = '';
  email: string = '';
  subject: string = '';
  message: string = '';
  isSubmitted: boolean = false;

  onSubmit(): void {
    console.log('Form submitted!');
    console.log('Name:', this.name);
    console.log('Email:', this.email);
    console.log('Subject:', this.subject);
    console.log('Message:', this.message);
    this.isSubmitted = true;
  }
}

src/app/contact/contact.html:

<div class="form-container">

  @if (isSubmitted) {
    <div class="success-message">
      <h2>✓ Message Sent!</h2>
      <p>Thank you for reaching out. We will get back to you soon.</p>
    </div>
  } @else {

    <h1>Contact Us</h1>

    <form #contactForm="ngForm" (ngSubmit)="onSubmit()">

      <div class="field">
        <label for="name">Your Name</label>
        <input
          id="name"
          type="text"
          name="name"
          [(ngModel)]="name"
          required
          minlength="2"
          placeholder="Rahul Sharma"
          #nameField="ngModel">

        @if (nameField.invalid && nameField.touched) {
          <div class="error">
            @if (nameField.errors?.['required']) {
              <span>Name is required</span>
            }
            @if (nameField.errors?.['minlength']) {
              <span>Name must be at least 2 characters</span>
            }
          </div>
        }
      </div>

      <div class="field">
        <label for="email">Email Address</label>
        <input
          id="email"
          type="email"
          name="email"
          [(ngModel)]="email"
          required
          email
          placeholder="rahul@example.com"
          #emailField="ngModel">

        @if (emailField.invalid && emailField.touched) {
          <div class="error">
            @if (emailField.errors?.['required']) {
              <span>Email is required</span>
            }
            @if (emailField.errors?.['email']) {
              <span>Please enter a valid email address</span>
            }
          </div>
        }
      </div>

      <div class="field">
        <label for="subject">Subject</label>
        <input
          id="subject"
          type="text"
          name="subject"
          [(ngModel)]="subject"
          required
          placeholder="How can we help?"
          #subjectField="ngModel">

        @if (subjectField.invalid && subjectField.touched) {
          <div class="error">
            <span>Subject is required</span>
          </div>
        }
      </div>

      <div class="field">
        <label for="message">Message</label>
        <textarea
          id="message"
          name="message"
          [(ngModel)]="message"
          required
          minlength="10"
          rows="5"
          placeholder="Write your message here..."
          #messageField="ngModel">
        </textarea>

        @if (messageField.invalid && messageField.touched) {
          <div class="error">
            @if (messageField.errors?.['required']) {
              <span>Message is required</span>
            }
            @if (messageField.errors?.['minlength']) {
              <span>Message must be at least 10 characters</span>
            }
          </div>
        }
      </div>

      <button
        type="submit"
        [disabled]="contactForm.invalid">
        Send Message
      </button>

    </form>

  }

</div>

There is a lot happening here. Let's understand each concept one by one.


2.3 — Understanding the Key Pieces

#contactForm="ngForm"

This is a template reference variable. When you write #contactForm="ngForm" on the <form> element, Angular creates an NgForm object representing the entire form and stores it in contactForm. You can then use contactForm anywhere in the template.

contactForm.invalid is true when any field in the form fails validation. That is why the submit button becomes disabled when the form is invalid — [disabled]="contactForm.invalid".

contactForm.valid, contactForm.dirty, contactForm.touched, contactForm.value — these are all properties available on the form reference.


name="name" on every input

Every input that participates in a template-driven form must have a name attribute. Angular uses the name attribute to register the field with the form. Without it, the field is invisible to Angular's form system.


[(ngModel)]="name"

This is two-way binding connecting the input to your TypeScript property. When the user types, this.name updates. When this.name changes in TypeScript, the input value updates.


required, minlength="2", email

These are Angular's built-in validators written directly as HTML attributes. required marks the field as mandatory. minlength sets the minimum character count. email validates that the value looks like an email address.

Behind the scenes, Angular reads these attributes and adds validation logic to the field.


#nameField="ngModel"

Just like #contactForm="ngForm" gives you the whole form, #nameField="ngModel" gives you an NgModel object for this specific field. This object has properties you can use to show or hide error messages.

The most important properties are:

nameField.valid — is this field currently passing all validators?

nameField.invalid — is this field currently failing any validator?

nameField.touched — has the user ever clicked into this field and then clicked away?

nameField.dirty — has the user typed anything in this field?

nameField.pristine — has the user NOT typed anything yet?

nameField.errors — an object containing all current validation errors.


Why touched matters for showing errors

You never want to show error messages the moment the page loads — before the user has even interacted with the form. That would be terrible UX. You only want to show errors after the user has interacted with a field and it is invalid.

touched becomes true after the user focuses a field and then moves focus away from it. So nameField.invalid && nameField.touched means "this field is invalid AND the user has already interacted with it" — the perfect condition for showing an error.


nameField.errors?.['required']

nameField.errors is an object like { required: true } when required validation fails, or { minlength: { requiredLength: 2, actualLength: 1 } } when minlength fails, or null when there are no errors.

The ?. is optional chaining — it safely accesses the property without throwing an error if errors is null.


src/app/contact/contact.css:

.form-container {
  max-width: 600px;
  margin: 48px auto;
  padding: 0 24px;
}

h1 {
  font-size: 32px;
  font-weight: 700;
  color: #1a1a2e;
  margin-bottom: 32px;
}

.field {
  margin-bottom: 22px;
}

label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #555;
  margin-bottom: 7px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

input, textarea {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 15px;
  color: #333;
  transition: border-color 0.2s;
  font-family: inherit;
  box-sizing: border-box;
}

input:focus, textarea:focus {
  outline: none;
  border-color: #0070f3;
  box-shadow: 0 0 0 3px rgba(0,112,243,0.1);
}

input.ng-invalid.ng-touched,
textarea.ng-invalid.ng-touched {
  border-color: #dc3545;
}

input.ng-valid.ng-touched,
textarea.ng-valid.ng-touched {
  border-color: #28a745;
}

.error {
  margin-top: 6px;
  font-size: 13px;
  color: #dc3545;
}

button {
  width: 100%;
  padding: 13px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

button:hover { background: #005ac1; }

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.success-message {
  text-align: center;
  padding: 64px 24px;
}

.success-message h2 {
  font-size: 28px;
  color: #28a745;
  margin-bottom: 12px;
}

.success-message p {
  color: #666;
  font-size: 16px;
}

Notice input.ng-invalid.ng-touched and input.ng-valid.ng-touched in the CSS. Angular automatically adds CSS classes to every form field based on its current state:

ng-valid — field passes all validators ng-invalid — field fails at least one validator ng-touched — user has interacted with field ng-untouched — user has not yet interacted with field ng-dirty — user has changed the value ng-pristine — user has not yet changed the value

You can style these classes directly in your CSS to visually indicate field states — red border for invalid, green for valid.


2.4 — Resetting the Form

To reset all fields back to empty after submission:

import { Component, ViewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

  @ViewChild('contactForm') contactForm!: NgForm;

  name: string = '';
  email: string = '';
  message: string = '';

  onSubmit(): void {
    console.log('Submitted:', { name: this.name, email: this.email, message: this.message });

    // Reset the form — clears values AND resets touched/dirty state
    this.contactForm.reset();
  }
}

NgForm.reset() clears all field values AND resets the form's state — touched, dirty, and invalid all go back to their initial state. This is important because if you just reset the TypeScript properties without calling .reset(), the form still thinks the fields were touched and would still show validation errors.


Chapter 3 — Reactive Forms


3.1 — The Philosophy of Reactive Forms

Reactive Forms are Angular's more powerful, more explicit forms approach. Instead of Angular reading your HTML template and building a form model from it, YOU build the form model in TypeScript and then connect the template to it.

This gives you:

Full control — you define every field, every validator, every initial value in TypeScript. There is no magic happening behind the scenes.

Type safety — because the form model is in TypeScript, you get autocomplete, type checking, and refactoring support.

Testability — you can test form logic without any DOM at all because the logic is in TypeScript.

Dynamic forms — adding and removing form fields at runtime is straightforward because you are working with objects in TypeScript.

Reactive — the form model is observable. You can subscribe to changes in specific fields or the entire form and react to them.


3.2 — Setting Up ReactiveFormsModule

Import ReactiveFormsModule in any component that uses reactive forms:

import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

}

3.3 — The Three Building Blocks of Reactive Forms

Before building anything, you need to understand the three classes that make up a reactive form:

FormControl — represents a single form field. It holds the current value, the validation state, and the validators for that one field.

import { FormControl, Validators } from '@angular/forms';

const emailControl = new FormControl('', [Validators.required, Validators.email]);
// First arg: initial value
// Second arg: array of validators

FormGroup — represents a group of form controls. A login form is a FormGroup with an email control and a password control. The group itself has a combined validity — the group is only valid if all its controls are valid.

import { FormGroup, FormControl, Validators } from '@angular/forms';

const loginForm = new FormGroup({
  email: new FormControl('', [Validators.required, Validators.email]),
  password: new FormControl('', [Validators.required, Validators.minLength(8)])
});

FormArray — represents a list of form controls or form groups. Used when you need a dynamic number of fields — like adding multiple phone numbers, or a list of education entries. We cover this in Chapter 5.


3.4 — FormBuilder — The Cleaner Syntax

Creating forms with new FormControl() and new FormGroup() everywhere gets verbose quickly. Angular provides FormBuilder — a service that lets you create the same form with a much cleaner syntax:

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);

  // Using FormBuilder — much cleaner
  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    age: [null, [Validators.required, Validators.min(18), Validators.max(100)]],
    role: ['user', Validators.required]
  });
}

this.fb.group({...}) creates a FormGroup. Each key is a field name. The value is an array where the first element is the initial value and the second element is the validators (or an array of validators).

This is exactly equivalent to:

registerForm = new FormGroup({
  fullName: new FormControl('', [Validators.required, Validators.minLength(2)]),
  email: new FormControl('', [Validators.required, Validators.email]),
  // ... etc
});

FormBuilder is just a convenience wrapper. Use it — it is the standard in real Angular projects.


3.5 — All Built-in Validators

Angular's Validators class provides these built-in validators:

Validators.required         // field must have a value
Validators.requiredTrue     // value must be exactly true (for checkboxes)
Validators.email            // value must look like an email
Validators.minLength(n)     // value must be at least n characters
Validators.maxLength(n)     // value must be at most n characters
Validators.min(n)           // number value must be >= n
Validators.max(n)           // number value must be <= n
Validators.pattern(regex)   // value must match the regex pattern

You can combine multiple validators in an array:

phone: ['', [
  Validators.required,
  Validators.minLength(10),
  Validators.maxLength(10),
  Validators.pattern(/^[0-9]+$/)  // only digits
]]

3.6 — Connecting the Form to the Template

In the template, you connect the reactive form using formGroup and formControlName directives:

src/app/register/register.html:

<div class="form-container">
  <h1>Create Account</h1>

  <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">

    <div class="field">
      <label>Full Name</label>
      <input
        type="text"
        formControlName="fullName"
        placeholder="Rahul Sharma">

      @if (registerForm.get('fullName')?.invalid &&
           registerForm.get('fullName')?.touched) {
        <div class="error">
          @if (registerForm.get('fullName')?.errors?.['required']) {
            <span>Full name is required</span>
          }
          @if (registerForm.get('fullName')?.errors?.['minlength']) {
            <span>Name must be at least 2 characters</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Email</label>
      <input
        type="email"
        formControlName="email"
        placeholder="rahul@example.com">

      @if (registerForm.get('email')?.invalid &&
           registerForm.get('email')?.touched) {
        <div class="error">
          @if (registerForm.get('email')?.errors?.['required']) {
            <span>Email is required</span>
          }
          @if (registerForm.get('email')?.errors?.['email']) {
            <span>Please enter a valid email</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Password</label>
      <input
        type="password"
        formControlName="password"
        placeholder="Minimum 8 characters">

      @if (registerForm.get('password')?.invalid &&
           registerForm.get('password')?.touched) {
        <div class="error">
          @if (registerForm.get('password')?.errors?.['required']) {
            <span>Password is required</span>
          }
          @if (registerForm.get('password')?.errors?.['minlength']) {
            <span>Password must be at least 8 characters</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Confirm Password</label>
      <input
        type="password"
        formControlName="confirmPassword"
        placeholder="Re-enter your password">

      @if (registerForm.errors?.['passwordMismatch'] &&
           registerForm.get('confirmPassword')?.touched) {
        <div class="error">
          <span>Passwords do not match</span>
        </div>
      }
    </div>

    <div class="field">
      <label>Age</label>
      <input
        type="number"
        formControlName="age"
        placeholder="Must be 18 or older">

      @if (registerForm.get('age')?.invalid &&
           registerForm.get('age')?.touched) {
        <div class="error">
          @if (registerForm.get('age')?.errors?.['required']) {
            <span>Age is required</span>
          }
          @if (registerForm.get('age')?.errors?.['min']) {
            <span>You must be at least 18 years old</span>
          }
          @if (registerForm.get('age')?.errors?.['max']) {
            <span>Please enter a valid age</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Role</label>
      <select formControlName="role">
        <option value="user">Regular User</option>
        <option value="author">Author</option>
        <option value="admin">Admin</option>
      </select>
    </div>

    <button
      type="submit"
      [disabled]="registerForm.invalid">
      Create Account
    </button>

  </form>
</div>

The key directives:

[formGroup]="registerForm" — binds the <form> element to your TypeScript FormGroup.

formControlName="fullName" — binds an input to the fullName FormControl inside the group. This is how the input and the form control stay connected. The value of formControlName must exactly match the key you used in fb.group({...}).

registerForm.get('fullName') — gets a reference to the fullName control so you can check its validity and errors.

registerForm.get('fullName')?.invalid — the ?. is optional chaining because get() can return null if the control name does not exist.


3.7 — Reading Form Values and Handling Submission

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);

  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    age: [null as number | null, [Validators.required, Validators.min(18)]],
    role: ['user']
  });

  isSubmitted: boolean = false;

  onSubmit(): void {
    if (this.registerForm.invalid) {
      // Mark all fields as touched so errors show
      this.registerForm.markAllAsTouched();
      return;
    }

    const formData = this.registerForm.value;
    console.log('Submitted:', formData);
    // formData.fullName, formData.email, formData.password, etc.

    this.isSubmitted = true;
    this.registerForm.reset();
  }
}

this.registerForm.value — returns a plain object with all the current field values. { fullName: 'Rahul', email: 'rahul@example.com', ... }.

this.registerForm.markAllAsTouched() — marks every single field as touched. This is very useful when the user clicks submit without filling anything in — you want to show all errors at once. Without this, errors only show for fields the user has individually clicked into and out of.

this.registerForm.reset() — resets all fields to their initial values and clears touched/dirty state.


3.8 — Getting Individual Controls More Cleanly

Writing registerForm.get('fullName') everywhere is verbose. A common pattern is to create getter properties for each control:

export class Register {

  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  // Getter properties for easy template access
  get fullName() { return this.registerForm.get('fullName')!; }
  get email() { return this.registerForm.get('email')!; }
  get password() { return this.registerForm.get('password')!; }
}

Now in the template you write fullName.invalid instead of registerForm.get('fullName')?.invalid. Much cleaner:

@if (fullName.invalid && fullName.touched) {
  <div class="error">
    @if (fullName.errors?.['required']) {
      <span>Full name is required</span>
    }
  </div>
}

3.9 — Updating Form Values Programmatically

Sometimes you need to update form values from TypeScript — like pre-populating a form with data loaded from an API:

// setValue — sets ALL fields, must provide every field
this.registerForm.setValue({
  fullName: 'Rahul Sharma',
  email: 'rahul@example.com',
  password: '',
  confirmPassword: '',
  age: 25,
  role: 'user'
});

// patchValue — sets only the fields you provide, ignores the rest
this.registerForm.patchValue({
  fullName: 'Rahul Sharma',
  email: 'rahul@example.com'
  // other fields unchanged
});

Use patchValue when you are pre-filling only some fields. Use setValue when you are filling the entire form.


Chapter 4 — Custom Validators


4.1 — Why Custom Validators?

Angular's built-in validators cover the basics — required, email, min, max, pattern. But real applications need much more specific validation:

A phone number must be exactly 10 digits and start with 6, 7, 8, or 9. A username cannot contain spaces. A confirm password field must match the password field. A date must be in the future. A price must be a multiple of 5.

None of these are covered by built-in validators. This is where custom validators come in.


4.2 — A Custom Validator Function

A validator is just a function. It receives an AbstractControl (which can be a FormControl, FormGroup, or FormArray) and returns either null (validation passed) or an error object (validation failed).

import { AbstractControl, ValidationErrors } from '@angular/forms';

// Custom validator: no spaces allowed
export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;

  if (!value) {
    return null; // if empty, let 'required' handle it
  }

  const hasSpaces = value.includes(' ');

  if (hasSpaces) {
    return { noSpaces: true };
    // The key 'noSpaces' is how you identify this error in the template
  }

  return null; // null means validation passed
}

The returned error object can contain any information you want. The key is the error name. The value is typically true or an object with more details:

// Simple — just flag the error
return { noSpaces: true };

// With details — useful for showing specific info in the error message
return { minLength: { required: 8, actual: value.length } };

4.3 — Using a Custom Validator

Apply it exactly like a built-in validator:

import { noSpacesValidator } from './validators/no-spaces.validator';

registerForm = this.fb.group({
  username: ['', [
    Validators.required,
    Validators.minLength(3),
    noSpacesValidator          // ← just add it to the array
  ]]
});

And check for it in the template:

@if (username.errors?.['noSpaces']) {
  <span>Username cannot contain spaces</span>
}

4.4 — A Validator with Configuration (Validator Factory)

If your validator needs configuration — like checking that a value is at least N characters but N is dynamic — you create a validator factory: a function that takes parameters and returns a validator function.

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Validator factory — takes a blacklist and returns a validator
export function notInListValidator(blacklist: string[]): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = (control.value as string)?.toLowerCase();

    if (!value) return null;

    const isBlacklisted = blacklist.includes(value);

    if (isBlacklisted) {
      return { notInList: { value: control.value } };
    }

    return null;
  };
}

Using it:

username: ['', [
  Validators.required,
  notInListValidator(['admin', 'root', 'system', 'test'])
]]

4.5 — Cross-Field Validation — The Password Match Validator

Cross-field validation is when you need to validate one field against another — the classic example being password and confirm password must match.

This validator goes on the FormGroup level, not on an individual control, because it needs to read two controls:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function passwordMatchValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;

    if (!password || !confirmPassword) {
      return null;
    }

    if (password !== confirmPassword) {
      return { passwordMismatch: true };
    }

    return null;
  };
}

Apply it to the FormGroup, not an individual control:

registerForm = this.fb.group(
  {
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required]
  },
  { validators: passwordMatchValidator() }   // ← second argument to group()
);

In the template, you check for this error on the form itself (not on a specific control) since it is a group-level error:

@if (registerForm.errors?.['passwordMismatch'] &&
     registerForm.get('confirmPassword')?.touched) {
  <div class="error">
    <span>Passwords do not match</span>
  </div>
}

Chapter 5 — FormArray — Dynamic Fields


5.1 — What is FormArray?

A FormArray is a list of form controls or form groups. You use it when the number of fields is not fixed — it changes at runtime based on user actions.

Examples: a skills list where the user can add any number of skills, an education history form where the user adds each degree, a checkout form where the user adds multiple delivery addresses.


5.2 — Building a Dynamic Skills Form

src/app/profile/profile.ts:

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormArray, Validators, AbstractControl } from '@angular/forms';

@Component({
  selector: 'app-profile',
  imports: [ReactiveFormsModule],
  templateUrl: './profile.html',
  styleUrl: './profile.css'
})
export class Profile {

  private fb = inject(FormBuilder);

  profileForm = this.fb.group({
    name: ['', Validators.required],
    bio: ['', [Validators.required, Validators.maxLength(200)]],
    skills: this.fb.array([
      this.fb.control('Angular', Validators.required),
      this.fb.control('TypeScript', Validators.required)
    ])
  });

  // Getter for easy access to the skills array
  get skills(): FormArray {
    return this.profileForm.get('skills') as FormArray;
  }

  get skillControls(): AbstractControl[] {
    return this.skills.controls;
  }

  addSkill(): void {
    // Add a new empty control to the array
    this.skills.push(this.fb.control('', Validators.required));
  }

  removeSkill(index: number): void {
    // Remove the control at this index
    this.skills.removeAt(index);
  }

  onSubmit(): void {
    if (this.profileForm.invalid) {
      this.profileForm.markAllAsTouched();
      return;
    }
    console.log('Profile:', this.profileForm.value);
  }
}

src/app/profile/profile.html:

<div class="form-container">
  <h1>Edit Profile</h1>

  <form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

    <div class="field">
      <label>Name</label>
      <input type="text" formControlName="name" placeholder="Your name">
      @if (profileForm.get('name')?.invalid && profileForm.get('name')?.touched) {
        <div class="error">Name is required</div>
      }
    </div>

    <div class="field">
      <label>Bio</label>
      <textarea formControlName="bio" rows="3" placeholder="Tell us about yourself"></textarea>
      <div class="char-count">
        {{ profileForm.get('bio')?.value?.length || 0 }} / 200
      </div>
      @if (profileForm.get('bio')?.errors?.['maxlength']) {
        <div class="error">Bio cannot exceed 200 characters</div>
      }
    </div>

    <div class="field">
      <label>Skills</label>

      <div formArrayName="skills" class="skills-list">
        @for (skill of skillControls; track $index; let i = $index) {
          <div class="skill-row">
            <input
              type="text"
              [formControlName]="i"
              placeholder="e.g. Angular, TypeScript">

            @if (skills.at(i).invalid && skills.at(i).touched) {
              <span class="error">Skill name is required</span>
            }

            <button
              type="button"
              class="remove-btn"
              (click)="removeSkill(i)"
              [disabled]="skills.length <= 1">
              ✕
            </button>
          </div>
        }
      </div>

      <button type="button" class="add-btn" (click)="addSkill()">
        + Add Skill
      </button>
    </div>

    <button type="submit" [disabled]="profileForm.invalid">
      Save Profile
    </button>

  </form>
</div>

Key things happening here:

formArrayName="skills" — connects the <div> wrapper to the skills FormArray in your form.

[formControlName]="i" — inside a formArrayName, you use the numeric index as the control name. i is the loop index.

this.skills.push(...) — adds a new control at the end of the array.

this.skills.removeAt(index) — removes the control at the given index.

this.skills.length — the number of controls currently in the array.


Chapter 6 — Async Validators


6.1 — What Are Async Validators?

Some validation requires checking with the server. "Is this username already taken?" "Does this email already have an account?" You cannot do this check synchronously because it requires an HTTP request.

Async validators return a Promise or Observable instead of returning the result directly. Angular waits for the async operation to complete before deciding if the field is valid.


6.2 — Building an Async Username Validator

import { inject } from '@angular/core';
import { AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';

// Simulated list of taken usernames
const takenUsernames = ['rahul', 'priya', 'admin', 'user123'];

export function usernameAvailableValidator(): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = control.value as string;

    if (!value || value.length < 3) {
      return of(null);  // 'of' creates an Observable that immediately emits a value
    }

    // timer(500) introduces a 500ms debounce
    // This prevents checking on every single keystroke
    return timer(500).pipe(
      switchMap(() => {
        // Simulating an HTTP call — in real app this would be an actual API call
        const isTaken = takenUsernames.includes(value.toLowerCase());

        if (isTaken) {
          return of({ usernameTaken: true });
        }
        return of(null);
      }),
      catchError(() => of(null))  // if the server errors, don't block the form
    );
  };
}

Using it in the form — async validators go as the THIRD argument to FormControl or fb.control(), after sync validators:

import { usernameAvailableValidator } from './validators/username-available.validator';

registerForm = this.fb.group({
  username: [
    '',
    [Validators.required, Validators.minLength(3)],  // sync validators — 2nd arg
    [usernameAvailableValidator()]                    // async validators — 3rd arg
  ]
});

In the template, Angular provides a pending state while the async validator is running — use it to show a loading indicator:

<div class="field">
  <label>Username</label>

  <div class="input-with-status">
    <input type="text" formControlName="username" placeholder="Choose a username">

    @if (registerForm.get('username')?.pending) {
      <span class="checking">Checking...</span>
    }

    @if (registerForm.get('username')?.valid && !registerForm.get('username')?.pending) {
      <span class="available">✓ Available</span>
    }
  </div>

  @if (registerForm.get('username')?.errors?.['required'] &&
       registerForm.get('username')?.touched) {
    <div class="error">Username is required</div>
  }

  @if (registerForm.get('username')?.errors?.['minlength']) {
    <div class="error">Username must be at least 3 characters</div>
  }

  @if (registerForm.get('username')?.errors?.['usernameTaken']) {
    <div class="error">This username is already taken. Please choose another.</div>
  }
</div>

pending is true while any async validator on that control is still running. Use it to show a "Checking..." spinner.


Chapter 7 — Listening to Form Changes


7.1 — valueChanges

Reactive forms give you an Observable called valueChanges that emits a new value every time the form's value changes. You can subscribe to it to react to any change:

import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  templateUrl: './search.html',
  styleUrl: './search.css'
})
export class Search implements OnInit, OnDestroy {

  private fb = inject(FormBuilder);
  private subscription?: Subscription;

  searchForm = this.fb.group({
    query: [''],
    category: ['all']
  });

  searchResults: string[] = [];

  ngOnInit(): void {
    // Listen to the entire form changing
    this.subscription = this.searchForm.valueChanges
      .pipe(
        debounceTime(300),          // wait 300ms after user stops typing
        distinctUntilChanged()      // only emit if value actually changed
      )
      .subscribe(value => {
        console.log('Form value changed:', value);
        this.performSearch(value.query || '', value.category || 'all');
      });
  }

  // You can also listen to a single control
  ngOnInit2(): void {
    this.searchForm.get('query')?.valueChanges
      .pipe(debounceTime(300))
      .subscribe(query => {
        console.log('Query changed:', query);
      });
  }

  performSearch(query: string, category: string): void {
    // Simulate search results
    if (query.length > 0) {
      this.searchResults = [
        `Result 1 for "${query}" in ${category}`,
        `Result 2 for "${query}" in ${category}`,
        `Result 3 for "${query}" in ${category}`
      ];
    } else {
      this.searchResults = [];
    }
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

This is the real-time search pattern. Every time the user types in the search box, valueChanges fires. debounceTime(300) waits until the user pauses typing for 300ms before running the search — preventing an API call on every single keystroke. distinctUntilChanged() prevents running the search if the value did not actually change.


7.2 — statusChanges

statusChanges emits whenever the form's validation status changes — from VALID to INVALID, INVALID to VALID, or PENDING while async validators run:

this.registerForm.statusChanges.subscribe(status => {
  console.log('Form status:', status);
  // status is 'VALID', 'INVALID', or 'PENDING'
});

Chapter 8 — A Complete Real-World Form Project


Let's build a complete User Registration and Profile system that demonstrates everything from this phase.


Setup

ng new forms-app --style=css
cd forms-app
ng g c pages/register --skip-tests
ng g c pages/login --skip-tests
ng g c pages/profile --skip-tests

Custom Validators File

src/app/validators/validators.ts:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  return (control.value as string).includes(' ')
    ? { noSpaces: true }
    : null;
}

export function passwordMatchValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;
    if (!password || !confirmPassword) return null;
    return password !== confirmPassword ? { passwordMismatch: true } : null;
  };
}

export function indianPhoneValidator(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  const phone = control.value.toString();
  const isValid = /^[6-9][0-9]{9}$/.test(phone);
  return isValid ? null : { invalidPhone: true };
}

Registration Form

src/app/pages/register/register.ts:

import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import {
  noSpacesValidator,
  passwordMatchValidator,
  indianPhoneValidator
} from '../../validators/validators';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);
  private router = inject(Router);

  isSubmitting = signal(false);
  submitSuccess = signal(false);

  form = this.fb.group(
    {
      fullName: ['', [Validators.required, Validators.minLength(2)]],
      username: ['', [Validators.required, Validators.minLength(3), noSpacesValidator]],
      email: ['', [Validators.required, Validators.email]],
      phone: ['', [Validators.required, indianPhoneValidator]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required],
      role: ['user', Validators.required],
      agreeToTerms: [false, Validators.requiredTrue]
    },
    { validators: passwordMatchValidator() }
  );

  get fullName() { return this.form.get('fullName')!; }
  get username() { return this.form.get('username')!; }
  get email() { return this.form.get('email')!; }
  get phone() { return this.form.get('phone')!; }
  get password() { return this.form.get('password')!; }
  get confirmPassword() { return this.form.get('confirmPassword')!; }
  get agreeToTerms() { return this.form.get('agreeToTerms')!; }

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    this.isSubmitting.set(true);

    // Simulate API call
    setTimeout(() => {
      console.log('Registration data:', this.form.value);
      this.isSubmitting.set(false);
      this.submitSuccess.set(true);
    }, 1500);
  }

  goToLogin(): void {
    this.router.navigate(['/login']);
  }
}

src/app/pages/register/register.html:

<div class="page">
  <div class="form-card">

    @if (submitSuccess()) {
      <div class="success">
        <div class="success-icon">✓</div>
        <h2>Account Created!</h2>
        <p>Welcome aboard. You can now log in.</p>
        <button (click)="goToLogin()">Go to Login</button>
      </div>
    } @else {

      <h1>Create Account</h1>
      <p class="subtitle">Join us today — it's free</p>

      <form [formGroup]="form" (ngSubmit)="onSubmit()">

        <div class="form-row">

          <div class="field">
            <label>Full Name</label>
            <input type="text" formControlName="fullName" placeholder="Rahul Sharma">
            @if (fullName.invalid && fullName.touched) {
              <div class="error">
                @if (fullName.errors?.['required']) { <span>Full name is required</span> }
                @if (fullName.errors?.['minlength']) { <span>At least 2 characters required</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Username</label>
            <input type="text" formControlName="username" placeholder="rahul_dev">
            @if (username.invalid && username.touched) {
              <div class="error">
                @if (username.errors?.['required']) { <span>Username is required</span> }
                @if (username.errors?.['minlength']) { <span>At least 3 characters required</span> }
                @if (username.errors?.['noSpaces']) { <span>Username cannot contain spaces</span> }
              </div>
            }
          </div>

        </div>

        <div class="form-row">

          <div class="field">
            <label>Email Address</label>
            <input type="email" formControlName="email" placeholder="rahul@example.com">
            @if (email.invalid && email.touched) {
              <div class="error">
                @if (email.errors?.['required']) { <span>Email is required</span> }
                @if (email.errors?.['email']) { <span>Enter a valid email address</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Phone Number</label>
            <input type="tel" formControlName="phone" placeholder="9876543210">
            @if (phone.invalid && phone.touched) {
              <div class="error">
                @if (phone.errors?.['required']) { <span>Phone is required</span> }
                @if (phone.errors?.['invalidPhone']) { <span>Enter a valid 10-digit Indian phone number</span> }
              </div>
            }
          </div>

        </div>

        <div class="form-row">

          <div class="field">
            <label>Password</label>
            <input type="password" formControlName="password" placeholder="Min. 8 characters">
            @if (password.invalid && password.touched) {
              <div class="error">
                @if (password.errors?.['required']) { <span>Password is required</span> }
                @if (password.errors?.['minlength']) { <span>At least 8 characters required</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Confirm Password</label>
            <input type="password" formControlName="confirmPassword" placeholder="Re-enter password">
            @if (form.errors?.['passwordMismatch'] && confirmPassword.touched) {
              <div class="error"><span>Passwords do not match</span></div>
            }
          </div>

        </div>

        <div class="field">
          <label>Account Type</label>
          <select formControlName="role">
            <option value="user">Regular User</option>
            <option value="author">Author</option>
            <option value="admin">Administrator</option>
          </select>
        </div>

        <div class="field checkbox-field">
          <label class="checkbox-label">
            <input type="checkbox" formControlName="agreeToTerms">
            I agree to the Terms of Service and Privacy Policy
          </label>
          @if (agreeToTerms.invalid && agreeToTerms.touched) {
            <div class="error">You must agree to the terms to continue</div>
          }
        </div>

        <button
          type="submit"
          [disabled]="form.invalid || isSubmitting()">
          {{ isSubmitting() ? 'Creating Account...' : 'Create Account' }}
        </button>

        <p class="login-link">
          Already have an account?
          <a (click)="goToLogin()" style="cursor:pointer; color:#0070f3">Sign in</a>
        </p>

      </form>

    }

  </div>
</div>

src/app/pages/register/register.css:

.page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f9fafb;
  padding: 40px 24px;
}

.form-card {
  background: white;
  padding: 40px;
  border-radius: 16px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
  width: 100%;
  max-width: 680px;
}

h1 {
  font-size: 28px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 6px;
}

.subtitle {
  color: #888;
  font-size: 15px;
  margin-bottom: 32px;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
}

.field {
  margin-bottom: 20px;
}

label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #555;
  margin-bottom: 7px;
  text-transform: uppercase;
  letter-spacing: 0.4px;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
select {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  font-size: 15px;
  transition: border-color 0.2s;
  box-sizing: border-box;
  font-family: inherit;
  color: #333;
}

input:focus, select:focus {
  outline: none;
  border-color: #0070f3;
  box-shadow: 0 0 0 3px rgba(0,112,243,0.08);
}

input.ng-invalid.ng-touched {
  border-color: #dc3545;
}

input.ng-valid.ng-dirty {
  border-color: #28a745;
}

.error {
  margin-top: 5px;
  font-size: 12px;
  color: #dc3545;
}

.checkbox-field { margin-top: 8px; }

.checkbox-label {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  font-size: 14px;
  color: #555;
  text-transform: none;
  letter-spacing: 0;
  cursor: pointer;
}

.checkbox-label input[type="checkbox"] {
  width: auto;
  margin-top: 2px;
  flex-shrink: 0;
}

button[type="submit"] {
  width: 100%;
  padding: 14px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  margin-top: 8px;
  transition: background 0.2s;
}

button[type="submit"]:hover { background: #005ac1; }
button[type="submit"]:disabled { background: #ccc; cursor: not-allowed; }

.login-link {
  text-align: center;
  margin-top: 20px;
  font-size: 14px;
  color: #888;
}

.success {
  text-align: center;
  padding: 40px 0;
}

.success-icon {
  width: 72px;
  height: 72px;
  background: #28a745;
  color: white;
  border-radius: 50%;
  font-size: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 20px;
}

.success h2 {
  font-size: 26px;
  color: #1a1a2e;
  margin-bottom: 8px;
}

.success p {
  color: #666;
  margin-bottom: 24px;
}

.success button {
  background: #0070f3;
  color: white;
  border: none;
  padding: 12px 28px;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}

App Routes

src/app/app.routes.ts:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'register',
    pathMatch: 'full'
  },
  {
    path: 'register',
    loadComponent: () => import('./pages/register/register').then(m => m.Register)
  }
];

src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet></router-outlet>`
})
export class App { }

src/styles.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

Run ng serve -o. You have a complete registration form with:

Required field validation on every field. Email format validation. Minimum length on name, username, and password. Custom no-spaces validator on username. Custom Indian phone number validator using regex. Password match cross-field validator on the group level. Terms agreement with requiredTrue. All errors show only after the user has touched a field. markAllAsTouched() reveals all errors when submit is clicked on an incomplete form. Loading state during fake submission. Success state after submission.


Phase 7 — Complete Summary

Here is everything you learned in this phase.

Two approaches to forms — Template-Driven for simple forms, logic in the HTML. Reactive Forms for complex forms, logic in TypeScript. Both are valid but Reactive Forms are more powerful.

Template-Driven FormsFormsModule required. #form="ngForm" on the <form> element. name attribute required on every input. [(ngModel)] for two-way binding. #field="ngModel" for field-level state. Angular CSS classes — ng-valid, ng-invalid, ng-touched, ng-dirty. form.reset() to clear the form.

Reactive FormsReactiveFormsModule required. FormControl, FormGroup, FormArray are the three building blocks. FormBuilder provides cleaner syntax. [formGroup] on the form element. formControlName on inputs. form.get('field') to access a control. form.value to get all values. markAllAsTouched() to trigger all error messages.

Built-in validatorsrequired, requiredTrue, email, minLength, maxLength, min, max, pattern — all available on Validators.

Custom validators — A function that takes AbstractControl and returns null or an error object. Apply them in the validators array exactly like built-in ones. Validator factories take configuration parameters and return a validator function. Cross-field validators go on the FormGroup level.

FormArray — A list of FormControl or FormGroup instances. Add with .push(). Remove with .removeAt(index). Access controls with .controls. Connect in template with formArrayName and index-based formControlName.

Async validators — Return a Promise or Observable instead of a value directly. Go as the third argument to FormControl. pending state is true while running. Good for checking username/email availability against an API.

valueChanges and statusChanges — Observables on forms and controls. valueChanges emits on every change. statusChanges emits when validity changes. Use debounceTime and distinctUntilChanged for search-as-you-type patterns. Always unsubscribe in ngOnDestroy.


What's Next — Phase 8

In Phase 8 we cover HTTP Client and APIs — connecting your Angular app to a real backend:

Setting up HttpClient for making API calls. GET, POST, PUT, PATCH, DELETE requests with typed responses. Handling loading states and error responses. HTTP interceptors — adding auth tokens to every request, global error handling, loading indicators. Environment variables for switching between development and production API URLs. Best practices for organizing API calls in services.

Phase 8 — HTTP Client & APIs

Chapter 1 — What is HTTP and Why Do You Need It? 1.1 — Your App Needs to Talk to the Outside World Everything you have built so far has used...