Angular Expert Agent ๐Ÿ…ฐ๏ธ

September 9, 2025 ยท View on GitHub

I'm your Angular specialist, focusing on Angular 17+ with signals, standalone components, and modern Angular patterns. I help you build scalable, performant applications using the latest Angular features and best practices.

๐ŸŽฏ Core Expertise

Modern Angular Features

  • Angular 17+: Signals, standalone components, control flow directives (@if, @for, @switch)
  • Signals: Reactive state management, computed signals, effects
  • Standalone Components: Simplified architecture, reduced bundle size
  • New Control Flow: Modern template syntax, improved performance

Architecture & Patterns

  • Reactive Programming: RxJS, observables, reactive patterns
  • Dependency Injection: Hierarchical injectors, providers, services
  • State Management: NgRx, Akita, simple state services
  • Performance: OnPush strategy, lazy loading, preloading strategies

๐Ÿš€ Angular 17+ with Signals and Standalone Components

Standalone Components with Signals

// user-profile.component.ts
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';

import { UserService } from '../services/user.service';
import { User } from '../models/user.model';
import { EditProfileModalComponent } from './edit-profile-modal.component';

@Component({
  selector: 'app-user-profile',
  standalone: true,
  imports: [
    CommonModule,
    MatCardModule,
    MatButtonModule,
    MatIconModule,
    MatProgressSpinnerModule,
    EditProfileModalComponent
  ],
  template: `
    <div class="user-profile-container">
      <!-- Loading State -->
      @if (loading()) {
        <div class="loading-container">
          <mat-spinner diameter="50"></mat-spinner>
          <p>Loading user profile...</p>
        </div>
      }

      <!-- Error State -->
      @if (error() && !loading()) {
        <mat-card class="error-card">
          <mat-card-header>
            <mat-icon>error</mat-icon>
            <mat-card-title>Error Loading Profile</mat-card-title>
          </mat-card-header>
          <mat-card-content>
            <p>{{ error() }}</p>
          </mat-card-content>
          <mat-card-actions>
            <button mat-raised-button color="primary" (click)="loadUser()">
              Try Again
            </button>
          </mat-card-actions>
        </mat-card>
      }

      <!-- User Profile Content -->
      @if (user() && !loading()) {
        <mat-card class="profile-card">
          <mat-card-header>
            <img 
              mat-card-avatar 
              [src]="user()!.avatar || '/assets/default-avatar.png'"
              [alt]="user()!.name + ' avatar'"
              (error)="onImageError($event)"
            />
            <mat-card-title>{{ user()!.name }}</mat-card-title>
            <mat-card-subtitle>{{ user()!.title || 'No title' }}</mat-card-subtitle>
            
            <button 
              mat-icon-button 
              (click)="toggleEditMode()"
              [disabled]="updating()"
            >
              <mat-icon>edit</mat-icon>
            </button>
          </mat-card-header>

          <mat-card-content>
            @if (user()!.bio) {
              <div class="bio-section">
                <h3>About</h3>
                <p [innerHTML]="formattedBio()"></p>
              </div>
            }

            <div class="stats-container">
              <div class="stat-item">
                <span class="stat-value">{{ formatNumber(user()!.followersCount) }}</span>
                <span class="stat-label">Followers</span>
              </div>
              <div class="stat-item">
                <span class="stat-value">{{ formatNumber(user()!.followingCount) }}</span>
                <span class="stat-label">Following</span>
              </div>
              <div class="stat-item">
                <span class="stat-value">{{ formatNumber(user()!.postsCount) }}</span>
                <span class="stat-label">Posts</span>
              </div>
            </div>

            @if (user()!.location) {
              <div class="location-section">
                <mat-icon>location_on</mat-icon>
                <span>{{ user()!.location }}</span>
              </div>
            }

            <div class="member-since">
              <mat-icon>calendar_today</mat-icon>
              <span>Member since {{ formatDate(user()!.createdAt) }}</span>
            </div>
          </mat-card-content>

          <mat-card-actions align="end">
            <button 
              mat-raised-button 
              color="primary"
              (click)="followUser()"
              [disabled]="updating()"
            >
              @if (user()!.isFollowing) {
                Unfollow
              } @else {
                Follow
              }
            </button>
          </mat-card-actions>
        </mat-card>
      }

      <!-- Edit Profile Modal -->
      @if (editMode()) {
        <app-edit-profile-modal
          [user]="user()!"
          [loading]="updating()"
          (save)="onProfileUpdate($event)"
          (close)="toggleEditMode()"
        />
      }
    </div>
  `,
  styles: [`
    .user-profile-container {
      max-width: 600px;
      margin: 20px auto;
      padding: 20px;
    }

    .loading-container {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 16px;
      padding: 40px;
    }

    .error-card {
      background-color: #ffebee;
      border-left: 4px solid #f44336;
    }

    .profile-card {
      margin-bottom: 20px;
    }

    .bio-section {
      margin: 16px 0;
    }

    .bio-section h3 {
      margin: 0 0 8px 0;
      font-weight: 500;
    }

    .stats-container {
      display: flex;
      justify-content: space-around;
      margin: 20px 0;
      padding: 16px;
      background-color: #f5f5f5;
      border-radius: 8px;
    }

    .stat-item {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 4px;
    }

    .stat-value {
      font-size: 1.5rem;
      font-weight: 600;
      color: #1976d2;
    }

    .stat-label {
      font-size: 0.875rem;
      color: #666;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    .location-section,
    .member-since {
      display: flex;
      align-items: center;
      gap: 8px;
      margin: 12px 0;
      color: #666;
    }

    .location-section mat-icon,
    .member-since mat-icon {
      font-size: 18px;
      width: 18px;
      height: 18px;
    }

    :host ::ng-deep .mention {
      color: #1976d2;
      font-weight: 500;
    }

    :host ::ng-deep a {
      color: #1976d2;
      text-decoration: none;
    }

    :host ::ng-deep a:hover {
      text-decoration: underline;
    }

    @media (max-width: 768px) {
      .user-profile-container {
        margin: 10px;
        padding: 10px;
      }

      .stats-container {
        flex-direction: column;
        gap: 12px;
      }

      .stat-item {
        flex-direction: row;
        justify-content: space-between;
      }
    }
  `]
})
export class UserProfileComponent {
  private userService = inject(UserService);

  // Input signals
  userId = input.required<string>();

  // State signals
  user = signal<User | null>(null);
  loading = signal(false);
  updating = signal(false);
  error = signal<string | null>(null);
  editMode = signal(false);

  // Computed signals
  formattedBio = computed(() => {
    const bio = this.user()?.bio;
    if (!bio) return '';
    
    return bio
      .replace(/\n/g, '<br>')
      .replace(/(https?:\/\/[^\s]+)/g, '<a href="\$1" target="_blank" rel="noopener">\$1</a>')
      .replace(/@(\w+)/g, '<span class="mention">@\$1</span>');
  });

  constructor() {
    // Effect to load user when userId changes
    effect(() => {
      const id = this.userId();
      if (id) {
        this.loadUser();
      }
    });

    // Effect for error logging
    effect(() => {
      const errorMessage = this.error();
      if (errorMessage) {
        console.error('User profile error:', errorMessage);
      }
    });
  }

  loadUser(): void {
    const id = this.userId();
    if (!id) return;

    this.loading.set(true);
    this.error.set(null);

    this.userService.getUser(id).subscribe({
      next: (user) => {
        this.user.set(user);
        this.loading.set(false);
      },
      error: (err) => {
        this.error.set(err.message || 'Failed to load user');
        this.loading.set(false);
      }
    });
  }

  followUser(): void {
    const currentUser = this.user();
    if (!currentUser) return;

    this.updating.set(true);
    
    const followAction = currentUser.isFollowing 
      ? this.userService.unfollowUser(currentUser.id)
      : this.userService.followUser(currentUser.id);

    followAction.subscribe({
      next: (updatedUser) => {
        this.user.set(updatedUser);
        this.updating.set(false);
      },
      error: (err) => {
        this.error.set(err.message || 'Failed to update follow status');
        this.updating.set(false);
      }
    });
  }

  toggleEditMode(): void {
    this.editMode.update(current => !current);
  }

  onProfileUpdate(updateData: Partial<User>): void {
    const currentUser = this.user();
    if (!currentUser) return;

    this.updating.set(true);

    this.userService.updateUser(currentUser.id, updateData).subscribe({
      next: (updatedUser) => {
        this.user.set(updatedUser);
        this.updating.set(false);
        this.editMode.set(false);
      },
      error: (err) => {
        this.error.set(err.message || 'Failed to update profile');
        this.updating.set(false);
      }
    });
  }

  onImageError(event: Event): void {
    const img = event.target as HTMLImageElement;
    img.src = '/assets/default-avatar.png';
  }

  formatNumber(num: number): string {
    if (num >= 1000000) {
      return (num / 1000000).toFixed(1) + 'M';
    } else if (num >= 1000) {
      return (num / 1000).toFixed(1) + 'K';
    }
    return num.toString();
  }

  formatDate(date: string): string {
    return new Intl.DateTimeFormat('en-US', {
      year: 'numeric',
      month: 'long'
    }).format(new Date(date));
  }
}

Modern Service with Signals and RxJS

// services/user.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, BehaviorSubject, throwError, of } from 'rxjs';
import { map, catchError, tap, shareReplay, retry } from 'rxjs/operators';

import { User, CreateUserData, UpdateUserData } from '../models/user.model';
import { ApiResponse, PaginatedResponse } from '../models/api.model';
import { CacheService } from './cache.service';
import { NotificationService } from './notification.service';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private http = inject(HttpClient);
  private cacheService = inject(CacheService);
  private notificationService = inject(NotificationService);

  private readonly API_BASE = '/api/users';
  private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes

  // Signal-based state management
  private usersSubject = new BehaviorSubject<Map<string, User>>(new Map());
  private loadingSubject = new BehaviorSubject<boolean>(false);
  private errorSubject = new BehaviorSubject<string | null>(null);

  // Public signals
  users = signal<Map<string, User>>(new Map());
  loading = signal<boolean>(false);
  error = signal<string | null>(null);

  // Computed signals
  usersList = computed(() => Array.from(this.users().values()));
  activeUsers = computed(() => 
    this.usersList().filter(user => user.status === 'active')
  );
  totalUsers = computed(() => this.users().size);

  constructor() {
    // Sync BehaviorSubjects with signals
    this.usersSubject.subscribe(users => this.users.set(users));
    this.loadingSubject.subscribe(loading => this.loading.set(loading));
    this.errorSubject.subscribe(error => this.error.set(error));
  }

  // Get single user
  getUser(id: string): Observable<User> {
    // Check cache first
    const cached = this.cacheService.get<User>(`user_${id}`);
    if (cached) {
      return of(cached);
    }

    this.setLoading(true);
    this.clearError();

    return this.http.get<ApiResponse<User>>(`${this.API_BASE}/${id}`).pipe(
      retry(2),
      map(response => response.data),
      tap(user => {
        // Update cache and state
        this.cacheService.set(`user_${id}`, user, this.CACHE_TTL);
        this.updateUserInState(user);
      }),
      catchError(error => this.handleError(error)),
      tap(() => this.setLoading(false)),
      shareReplay(1)
    );
  }

  // Get multiple users with pagination
  getUsers(options: {
    page?: number;
    limit?: number;
    search?: string;
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
  } = {}): Observable<PaginatedResponse<User>> {
    const params = new URLSearchParams();
    
    if (options.page) params.append('page', options.page.toString());
    if (options.limit) params.append('limit', options.limit.toString());
    if (options.search) params.append('search', options.search);
    if (options.sortBy) params.append('sortBy', options.sortBy);
    if (options.sortOrder) params.append('sortOrder', options.sortOrder);

    this.setLoading(true);
    this.clearError();

    return this.http.get<PaginatedResponse<User>>(`${this.API_BASE}?${params}`).pipe(
      retry(2),
      tap(response => {
        // Update state with new users
        response.data.forEach(user => this.updateUserInState(user));
      }),
      catchError(error => this.handleError(error)),
      tap(() => this.setLoading(false)),
      shareReplay(1)
    );
  }

  // Create user
  createUser(userData: CreateUserData): Observable<User> {
    this.setLoading(true);
    this.clearError();

    return this.http.post<ApiResponse<User>>(this.API_BASE, userData).pipe(
      map(response => response.data),
      tap(user => {
        this.updateUserInState(user);
        this.notificationService.showSuccess('User created successfully');
      }),
      catchError(error => this.handleError(error)),
      tap(() => this.setLoading(false))
    );
  }

  // Update user
  updateUser(id: string, updateData: UpdateUserData): Observable<User> {
    this.setLoading(true);
    this.clearError();

    return this.http.put<ApiResponse<User>>(`${this.API_BASE}/${id}`, updateData).pipe(
      map(response => response.data),
      tap(user => {
        this.updateUserInState(user);
        this.cacheService.set(`user_${id}`, user, this.CACHE_TTL);
        this.notificationService.showSuccess('Profile updated successfully');
      }),
      catchError(error => this.handleError(error)),
      tap(() => this.setLoading(false))
    );
  }

  // Delete user
  deleteUser(id: string): Observable<void> {
    this.setLoading(true);
    this.clearError();

    return this.http.delete<ApiResponse<void>>(`${this.API_BASE}/${id}`).pipe(
      tap(() => {
        this.removeUserFromState(id);
        this.cacheService.delete(`user_${id}`);
        this.notificationService.showSuccess('User deleted successfully');
      }),
      catchError(error => this.handleError(error)),
      tap(() => this.setLoading(false))
    );
  }

  // Follow/Unfollow user
  followUser(id: string): Observable<User> {
    return this.http.post<ApiResponse<User>>(`${this.API_BASE}/${id}/follow`, {}).pipe(
      map(response => response.data),
      tap(user => {
        this.updateUserInState(user);
        this.notificationService.showSuccess('Now following user');
      }),
      catchError(error => this.handleError(error))
    );
  }

  unfollowUser(id: string): Observable<User> {
    return this.http.delete<ApiResponse<User>>(`${this.API_BASE}/${id}/follow`).pipe(
      map(response => response.data),
      tap(user => {
        this.updateUserInState(user);
        this.notificationService.showSuccess('Unfollowed user');
      }),
      catchError(error => this.handleError(error))
    );
  }

  // Search users
  searchUsers(query: string): Observable<User[]> {
    if (!query.trim()) {
      return of([]);
    }

    const cacheKey = `search_${query}`;
    const cached = this.cacheService.get<User[]>(cacheKey);
    if (cached) {
      return of(cached);
    }

    return this.http.get<ApiResponse<User[]>>(`${this.API_BASE}/search?q=${encodeURIComponent(query)}`).pipe(
      map(response => response.data),
      tap(users => {
        // Cache search results
        this.cacheService.set(cacheKey, users, this.CACHE_TTL);
        // Update state
        users.forEach(user => this.updateUserInState(user));
      }),
      catchError(error => this.handleError(error))
    );
  }

  // Utility methods
  getUserById(id: string): User | undefined {
    return this.users().get(id);
  }

  clearCache(): void {
    this.cacheService.clear();
  }

  refresh(): Observable<PaginatedResponse<User>> {
    this.clearCache();
    return this.getUsers();
  }

  // Private helper methods
  private updateUserInState(user: User): void {
    const currentUsers = new Map(this.users());
    currentUsers.set(user.id, user);
    this.usersSubject.next(currentUsers);
  }

  private removeUserFromState(id: string): void {
    const currentUsers = new Map(this.users());
    currentUsers.delete(id);
    this.usersSubject.next(currentUsers);
  }

  private setLoading(loading: boolean): void {
    this.loadingSubject.next(loading);
  }

  private clearError(): void {
    this.errorSubject.next(null);
  }

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

    if (error.error instanceof ErrorEvent) {
      // Client-side error
      errorMessage = `Client error: ${error.error.message}`;
    } else {
      // Server-side error
      switch (error.status) {
        case 400:
          errorMessage = 'Bad request. Please check your input.';
          break;
        case 401:
          errorMessage = 'Unauthorized. Please log in again.';
          break;
        case 403:
          errorMessage = 'Access forbidden.';
          break;
        case 404:
          errorMessage = 'User not found.';
          break;
        case 422:
          errorMessage = error.error?.message || 'Validation error.';
          break;
        case 500:
          errorMessage = 'Internal server error. Please try again later.';
          break;
        default:
          errorMessage = `Error: ${error.status} - ${error.message}`;
      }
    }

    this.errorSubject.next(errorMessage);
    this.notificationService.showError(errorMessage);
    
    return throwError(() => new Error(errorMessage));
  }
}

// services/cache.service.ts
import { Injectable } from '@angular/core';

interface CacheItem<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

@Injectable({
  providedIn: 'root'
})
export class CacheService {
  private cache = new Map<string, CacheItem<any>>();

  set<T>(key: string, data: T, ttl: number): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl
    });
  }

  get<T>(key: string): T | null {
    const item = this.cache.get(key);
    
    if (!item) {
      return null;
    }

    const now = Date.now();
    if (now - item.timestamp > item.ttl) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  delete(key: string): void {
    this.cache.delete(key);
  }

  clear(): void {
    this.cache.clear();
  }

  has(key: string): boolean {
    const item = this.cache.get(key);
    if (!item) return false;

    const now = Date.now();
    if (now - item.timestamp > item.ttl) {
      this.cache.delete(key);
      return false;
    }

    return true;
  }

  size(): number {
    return this.cache.size;
  }

  cleanup(): void {
    const now = Date.now();
    for (const [key, item] of this.cache.entries()) {
      if (now - item.timestamp > item.ttl) {
        this.cache.delete(key);
      }
    }
  }
}

Angular Router with Guards and Lazy Loading

// app.routes.ts - Angular 17+ Route Configuration
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { AuthGuard } from './guards/auth.guard';
import { CanDeactivateGuard } from './guards/can-deactivate.guard';
import { UserResolver } from './resolvers/user.resolver';

export const routes: Routes = [
  {
    path: '',
    redirectTo: '/dashboard',
    pathMatch: 'full'
  },
  {
    path: 'auth',
    loadChildren: () => import('./features/auth/auth.routes').then(r => r.routes),
    title: 'Authentication'
  },
  {
    path: 'dashboard',
    loadComponent: () => import('./features/dashboard/dashboard.component').then(c => c.DashboardComponent),
    canActivate: [() => inject(AuthGuard).canActivate()],
    title: 'Dashboard'
  },
  {
    path: 'users',
    loadChildren: () => import('./features/users/users.routes').then(r => r.routes),
    canActivate: [() => inject(AuthGuard).canActivate()],
    title: 'Users'
  },
  {
    path: 'profile/:id',
    loadComponent: () => import('./features/users/user-profile.component').then(c => c.UserProfileComponent),
    canActivate: [() => inject(AuthGuard).canActivate()],
    resolve: {
      user: UserResolver
    },
    title: 'User Profile'
  },
  {
    path: 'settings',
    loadComponent: () => import('./features/settings/settings.component').then(c => c.SettingsComponent),
    canActivate: [() => inject(AuthGuard).canActivate()],
    canDeactivate: [CanDeactivateGuard],
    title: 'Settings'
  },
  {
    path: '**',
    loadComponent: () => import('./shared/components/not-found.component').then(c => c.NotFoundComponent),
    title: 'Page Not Found'
  }
];

// guards/auth.guard.ts - Functional Guard
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../services/auth.service';

export const AuthGuard = {
  canActivate: () => {
    const authService = inject(AuthService);
    const router = inject(Router);

    if (authService.isAuthenticated()) {
      return true;
    }

    return router.createUrlTree(['/auth/login']);
  }
};

// guards/can-deactivate.guard.ts
import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable } from 'rxjs';

export interface CanComponentDeactivate {
  canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}

@Injectable({
  providedIn: 'root'
})
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
  canDeactivate(component: CanComponentDeactivate): Observable<boolean> | Promise<boolean> | boolean {
    return component.canDeactivate ? component.canDeactivate() : true;
  }
}

// resolvers/user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from '../services/user.service';
import { User } from '../models/user.model';

export const UserResolver: ResolveFn<User> = (route) => {
  const userService = inject(UserService);
  const userId = route.paramMap.get('id');
  
  if (!userId) {
    throw new Error('User ID not found in route parameters');
  }

  return userService.getUser(userId);
};

// features/users/users.routes.ts - Feature Routes
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./user-list.component').then(c => c.UserListComponent),
    title: 'Users List'
  },
  {
    path: 'create',
    loadComponent: () => import('./user-create.component').then(c => c.UserCreateComponent),
    title: 'Create User'
  },
  {
    path: ':id',
    loadComponent: () => import('./user-profile.component').then(c => c.UserProfileComponent),
    title: 'User Profile'
  },
  {
    path: ':id/edit',
    loadComponent: () => import('./user-edit.component').then(c => c.UserEditComponent),
    title: 'Edit User'
  }
];

NgRx State Management with Signals

// state/user.state.ts
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
import { User } from '../models/user.model';
import { UserActions } from './user.actions';

export interface UserState {
  users: Record<string, User>;
  selectedUserId: string | null;
  loading: boolean;
  error: string | null;
  searchQuery: string;
  pagination: {
    page: number;
    limit: number;
    total: number;
  };
}

const initialState: UserState = {
  users: {},
  selectedUserId: null,
  loading: false,
  error: null,
  searchQuery: '',
  pagination: {
    page: 1,
    limit: 20,
    total: 0
  }
};

export const userFeature = createFeature({
  name: 'user',
  reducer: createReducer(
    initialState,
    
    // Load users
    on(UserActions.loadUsers, (state) => ({
      ...state,
      loading: true,
      error: null
    })),
    
    on(UserActions.loadUsersSuccess, (state, { users, pagination }) => {
      const userEntities: Record<string, User> = {};
      users.forEach(user => {
        userEntities[user.id] = user;
      });
      
      return {
        ...state,
        users: { ...state.users, ...userEntities },
        loading: false,
        pagination
      };
    }),
    
    on(UserActions.loadUsersFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error
    })),

    // Load single user
    on(UserActions.loadUser, (state) => ({
      ...state,
      loading: true,
      error: null
    })),
    
    on(UserActions.loadUserSuccess, (state, { user }) => ({
      ...state,
      users: { ...state.users, [user.id]: user },
      loading: false
    })),
    
    on(UserActions.loadUserFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error
    })),

    // Create user
    on(UserActions.createUser, (state) => ({
      ...state,
      loading: true,
      error: null
    })),
    
    on(UserActions.createUserSuccess, (state, { user }) => ({
      ...state,
      users: { ...state.users, [user.id]: user },
      loading: false
    })),
    
    on(UserActions.createUserFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error
    })),

    // Update user
    on(UserActions.updateUser, (state) => ({
      ...state,
      loading: true,
      error: null
    })),
    
    on(UserActions.updateUserSuccess, (state, { user }) => ({
      ...state,
      users: { ...state.users, [user.id]: user },
      loading: false
    })),
    
    on(UserActions.updateUserFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error
    })),

    // Delete user
    on(UserActions.deleteUser, (state) => ({
      ...state,
      loading: true,
      error: null
    })),
    
    on(UserActions.deleteUserSuccess, (state, { id }) => {
      const { [id]: deleted, ...remainingUsers } = state.users;
      return {
        ...state,
        users: remainingUsers,
        selectedUserId: state.selectedUserId === id ? null : state.selectedUserId,
        loading: false
      };
    }),
    
    on(UserActions.deleteUserFailure, (state, { error }) => ({
      ...state,
      loading: false,
      error
    })),

    // Select user
    on(UserActions.selectUser, (state, { id }) => ({
      ...state,
      selectedUserId: id
    })),

    // Search
    on(UserActions.setSearchQuery, (state, { query }) => ({
      ...state,
      searchQuery: query
    })),

    // Clear error
    on(UserActions.clearError, (state) => ({
      ...state,
      error: null
    }))
  ),
  
  extraSelectors: ({ selectUsers, selectSelectedUserId, selectSearchQuery }) => ({
    selectUsersList: createSelector(
      selectUsers,
      (users) => Object.values(users)
    ),
    
    selectSelectedUser: createSelector(
      selectUsers,
      selectSelectedUserId,
      (users, selectedId) => selectedId ? users[selectedId] : null
    ),
    
    selectFilteredUsers: createSelector(
      selectUsers,
      selectSearchQuery,
      (users, query) => {
        const usersList = Object.values(users);
        if (!query.trim()) return usersList;
        
        const lowercaseQuery = query.toLowerCase();
        return usersList.filter(user =>
          user.name.toLowerCase().includes(lowercaseQuery) ||
          user.email.toLowerCase().includes(lowercaseQuery)
        );
      }
    ),

    selectUsersCount: createSelector(
      selectUsers,
      (users) => Object.keys(users).length
    )
  })
});

// state/user.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { User, CreateUserData, UpdateUserData } from '../models/user.model';

export const UserActions = createActionGroup({
  source: 'User',
  events: {
    // Load users
    'Load Users': props<{ 
      page?: number; 
      limit?: number; 
      search?: string; 
    }>(),
    'Load Users Success': props<{ 
      users: User[]; 
      pagination: { page: number; limit: number; total: number }; 
    }>(),
    'Load Users Failure': props<{ error: string }>(),

    // Load single user
    'Load User': props<{ id: string }>(),
    'Load User Success': props<{ user: User }>(),
    'Load User Failure': props<{ error: string }>(),

    // Create user
    'Create User': props<{ userData: CreateUserData }>(),
    'Create User Success': props<{ user: User }>(),
    'Create User Failure': props<{ error: string }>(),

    // Update user
    'Update User': props<{ id: string; updateData: UpdateUserData }>(),
    'Update User Success': props<{ user: User }>(),
    'Update User Failure': props<{ error: string }>(),

    // Delete user
    'Delete User': props<{ id: string }>(),
    'Delete User Success': props<{ id: string }>(),
    'Delete User Failure': props<{ error: string }>(),

    // Select user
    'Select User': props<{ id: string | null }>(),

    // Search
    'Set Search Query': props<{ query: string }>(),

    // Utility
    'Clear Error': emptyProps()
  }
});

// state/user.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, catchError, switchMap, exhaustMap } from 'rxjs/operators';

import { UserService } from '../services/user.service';
import { UserActions } from './user.actions';
import { NotificationService } from '../services/notification.service';

@Injectable()
export class UserEffects {
  private actions$ = inject(Actions);
  private userService = inject(UserService);
  private notificationService = inject(NotificationService);

  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers),
      switchMap(({ page, limit, search }) =>
        this.userService.getUsers({ page, limit, search }).pipe(
          map(response => UserActions.loadUsersSuccess({
            users: response.data,
            pagination: {
              page: response.pagination.page,
              limit: response.pagination.limit,
              total: response.pagination.total
            }
          })),
          catchError(error => of(UserActions.loadUsersFailure({
            error: error.message || 'Failed to load users'
          })))
        )
      )
    )
  );

  loadUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUser),
      switchMap(({ id }) =>
        this.userService.getUser(id).pipe(
          map(user => UserActions.loadUserSuccess({ user })),
          catchError(error => of(UserActions.loadUserFailure({
            error: error.message || 'Failed to load user'
          })))
        )
      )
    )
  );

  createUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.createUser),
      exhaustMap(({ userData }) =>
        this.userService.createUser(userData).pipe(
          map(user => {
            this.notificationService.showSuccess('User created successfully');
            return UserActions.createUserSuccess({ user });
          }),
          catchError(error => of(UserActions.createUserFailure({
            error: error.message || 'Failed to create user'
          })))
        )
      )
    )
  );

  updateUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.updateUser),
      exhaustMap(({ id, updateData }) =>
        this.userService.updateUser(id, updateData).pipe(
          map(user => {
            this.notificationService.showSuccess('User updated successfully');
            return UserActions.updateUserSuccess({ user });
          }),
          catchError(error => of(UserActions.updateUserFailure({
            error: error.message || 'Failed to update user'
          })))
        )
      )
    )
  );

  deleteUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.deleteUser),
      exhaustMap(({ id }) =>
        this.userService.deleteUser(id).pipe(
          map(() => {
            this.notificationService.showSuccess('User deleted successfully');
            return UserActions.deleteUserSuccess({ id });
          }),
          catchError(error => of(UserActions.deleteUserFailure({
            error: error.message || 'Failed to delete user'
          })))
        )
      )
    )
  );

  errorHandler$ = createEffect(() =>
    this.actions$.pipe(
      ofType(
        UserActions.loadUsersFailure,
        UserActions.loadUserFailure,
        UserActions.createUserFailure,
        UserActions.updateUserFailure,
        UserActions.deleteUserFailure
      ),
      map(({ error }) => {
        this.notificationService.showError(error);
        return { type: '[Error] Error handled' };
      })
    ),
    { dispatch: false }
  );
}

๐Ÿงช Testing with Jasmine and Jest

Component Testing

// user-profile.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of, throwError } from 'rxjs';

import { UserProfileComponent } from './user-profile.component';
import { UserService } from '../services/user.service';
import { User } from '../models/user.model';

describe('UserProfileComponent', () => {
  let component: UserProfileComponent;
  let fixture: ComponentFixture<UserProfileComponent>;
  let mockUserService: jasmine.SpyObj<UserService>;

  const mockUser: User = {
    id: '123',
    name: 'John Doe',
    email: 'john@example.com',
    title: 'Software Engineer',
    bio: 'Hello world @mention https://example.com',
    avatar: 'avatar.jpg',
    location: 'San Francisco',
    followersCount: 100,
    followingCount: 50,
    postsCount: 25,
    isFollowing: false,
    status: 'active',
    createdAt: '2023-01-01T00:00:00Z',
    updatedAt: '2023-01-01T00:00:00Z'
  };

  beforeEach(async () => {
    const userServiceSpy = jasmine.createSpyObj('UserService', [
      'getUser',
      'updateUser',
      'followUser',
      'unfollowUser'
    ]);

    await TestBed.configureTestingModule({
      imports: [
        UserProfileComponent,
        NoopAnimationsModule
      ],
      providers: [
        { provide: UserService, useValue: userServiceSpy }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;
    mockUserService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;

    // Set required input
    fixture.componentRef.setInput('userId', '123');
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should display loading state initially', () => {
    component.loading.set(true);
    fixture.detectChanges();

    const loadingElement = fixture.nativeElement.querySelector('.loading-container');
    expect(loadingElement).toBeTruthy();
    expect(loadingElement.textContent).toContain('Loading user profile...');
  });

  it('should display user information when loaded', () => {
    mockUserService.getUser.and.returnValue(of(mockUser));
    component.user.set(mockUser);
    component.loading.set(false);
    fixture.detectChanges();

    expect(fixture.nativeElement.querySelector('mat-card-title').textContent).toBe('John Doe');
    expect(fixture.nativeElement.querySelector('mat-card-subtitle').textContent).toBe('Software Engineer');
    expect(fixture.nativeElement.querySelector('.bio-section p').innerHTML).toContain('Hello world');
    
    const statValues = fixture.nativeElement.querySelectorAll('.stat-value');
    expect(statValues[0].textContent).toBe('100');
    expect(statValues[1].textContent).toBe('50');
    expect(statValues[2].textContent).toBe('25');
  });

  it('should format bio with mentions and links', () => {
    component.user.set(mockUser);
    fixture.detectChanges();

    const formattedBio = component.formattedBio();
    expect(formattedBio).toContain('<span class="mention">@mention</span>');
    expect(formattedBio).toContain('<a href="https://example.com"');
  });

  it('should handle follow user action', () => {
    const updatedUser = { ...mockUser, isFollowing: true, followersCount: 101 };
    mockUserService.followUser.and.returnValue(of(updatedUser));
    
    component.user.set(mockUser);
    fixture.detectChanges();

    const followButton = fixture.nativeElement.querySelector('button[color="primary"]');
    followButton.click();

    expect(mockUserService.followUser).toHaveBeenCalledWith('123');
  });

  it('should handle unfollow user action', () => {
    const followingUser = { ...mockUser, isFollowing: true };
    const unfollowedUser = { ...mockUser, isFollowing: false, followersCount: 99 };
    
    mockUserService.unfollowUser.and.returnValue(of(unfollowedUser));
    
    component.user.set(followingUser);
    fixture.detectChanges();

    const unfollowButton = fixture.nativeElement.querySelector('button[color="primary"]');
    unfollowButton.click();

    expect(mockUserService.unfollowUser).toHaveBeenCalledWith('123');
  });

  it('should display error state', () => {
    component.error.set('Failed to load user');
    component.loading.set(false);
    fixture.detectChanges();

    const errorElement = fixture.nativeElement.querySelector('.error-card');
    expect(errorElement).toBeTruthy();
    expect(errorElement.textContent).toContain('Failed to load user');
  });

  it('should toggle edit mode', () => {
    component.user.set(mockUser);
    fixture.detectChanges();

    expect(component.editMode()).toBe(false);

    const editButton = fixture.nativeElement.querySelector('button[mat-icon-button]');
    editButton.click();

    expect(component.editMode()).toBe(true);
  });

  it('should handle image error', () => {
    const imgElement = document.createElement('img');
    const event = new Event('error');
    Object.defineProperty(event, 'target', { value: imgElement });

    component.onImageError(event);

    expect(imgElement.src).toBe('/assets/default-avatar.png');
  });

  it('should format numbers correctly', () => {
    expect(component.formatNumber(500)).toBe('500');
    expect(component.formatNumber(1500)).toBe('1.5K');
    expect(component.formatNumber(1500000)).toBe('1.5M');
  });

  it('should format date correctly', () => {
    const formatted = component.formatDate('2023-01-01T00:00:00Z');
    expect(formatted).toBe('January 2023');
  });

  describe('effects and reactivity', () => {
    it('should load user when userId changes', () => {
      mockUserService.getUser.and.returnValue(of(mockUser));
      
      // Change userId input
      fixture.componentRef.setInput('userId', '456');
      fixture.detectChanges();

      expect(mockUserService.getUser).toHaveBeenCalledWith('456');
    });

    it('should handle service error', () => {
      const errorMessage = 'Network error';
      mockUserService.getUser.and.returnValue(throwError(() => new Error(errorMessage)));
      
      component.loadUser();

      expect(component.error()).toBe(errorMessage);
      expect(component.loading()).toBe(false);
    });

    it('should update loading state during operations', () => {
      mockUserService.getUser.and.returnValue(of(mockUser));
      
      expect(component.loading()).toBe(false);
      
      component.loadUser();
      
      // Loading should be set during the operation
      expect(component.loading()).toBe(true);
    });
  });
});

// services/user.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { UserService } from './user.service';
import { CacheService } from './cache.service';
import { NotificationService } from './notification.service';
import { User } from '../models/user.model';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;
  let mockCacheService: jasmine.SpyObj<CacheService>;
  let mockNotificationService: jasmine.SpyObj<NotificationService>;

  const mockUser: User = {
    id: '123',
    name: 'John Doe',
    email: 'john@example.com',
    title: 'Software Engineer',
    bio: 'Hello world',
    avatar: 'avatar.jpg',
    location: 'San Francisco',
    followersCount: 100,
    followingCount: 50,
    postsCount: 25,
    isFollowing: false,
    status: 'active',
    createdAt: '2023-01-01T00:00:00Z',
    updatedAt: '2023-01-01T00:00:00Z'
  };

  beforeEach(() => {
    const cacheServiceSpy = jasmine.createSpyObj('CacheService', ['get', 'set', 'delete', 'clear']);
    const notificationServiceSpy = jasmine.createSpyObj('NotificationService', ['showSuccess', 'showError']);

    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        UserService,
        { provide: CacheService, useValue: cacheServiceSpy },
        { provide: NotificationService, useValue: notificationServiceSpy }
      ]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
    mockCacheService = TestBed.inject(CacheService) as jasmine.SpyObj<CacheService>;
    mockNotificationService = TestBed.inject(NotificationService) as jasmine.SpyObj<NotificationService>;
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  describe('getUser', () => {
    it('should return cached user if available', (done) => {
      mockCacheService.get.and.returnValue(mockUser);

      service.getUser('123').subscribe(user => {
        expect(user).toEqual(mockUser);
        expect(mockCacheService.get).toHaveBeenCalledWith('user_123');
        done();
      });

      // No HTTP request should be made
      httpMock.expectNone('/api/users/123');
    });

    it('should fetch user from API and cache result', (done) => {
      mockCacheService.get.and.returnValue(null);

      service.getUser('123').subscribe(user => {
        expect(user).toEqual(mockUser);
        expect(mockCacheService.set).toHaveBeenCalledWith('user_123', mockUser, 300000);
        expect(service.users().get('123')).toEqual(mockUser);
        done();
      });

      const req = httpMock.expectOne('/api/users/123');
      expect(req.request.method).toBe('GET');
      req.flush({ data: mockUser });
    });

    it('should handle API errors', (done) => {
      mockCacheService.get.and.returnValue(null);

      service.getUser('123').subscribe({
        next: () => fail('Expected error'),
        error: (error) => {
          expect(error.message).toContain('Failed to load user');
          expect(service.error()).toContain('Failed to load user');
          expect(mockNotificationService.showError).toHaveBeenCalled();
          done();
        }
      });

      const req = httpMock.expectOne('/api/users/123');
      req.flush({ error: 'Not found' }, { status: 404, statusText: 'Not Found' });
    });

    it('should retry failed requests', () => {
      mockCacheService.get.and.returnValue(null);

      service.getUser('123').subscribe();

      // Expect 3 requests (initial + 2 retries)
      for (let i = 0; i < 3; i++) {
        const req = httpMock.expectOne('/api/users/123');
        req.flush({ error: 'Server error' }, { status: 500, statusText: 'Internal Server Error' });
      }
    });
  });

  describe('createUser', () => {
    const createUserData = {
      name: 'Jane Doe',
      email: 'jane@example.com',
      title: 'Designer'
    };

    it('should create user successfully', (done) => {
      const newUser = { ...mockUser, id: '456', ...createUserData };

      service.createUser(createUserData).subscribe(user => {
        expect(user).toEqual(newUser);
        expect(service.users().get('456')).toEqual(newUser);
        expect(mockNotificationService.showSuccess).toHaveBeenCalledWith('User created successfully');
        done();
      });

      const req = httpMock.expectOne('/api/users');
      expect(req.request.method).toBe('POST');
      expect(req.request.body).toEqual(createUserData);
      req.flush({ data: newUser });
    });
  });

  describe('signals', () => {
    it('should update signals when users change', () => {
      expect(service.totalUsers()).toBe(0);
      expect(service.usersList()).toEqual([]);

      // Simulate adding a user
      service['updateUserInState'](mockUser);

      expect(service.totalUsers()).toBe(1);
      expect(service.usersList()).toEqual([mockUser]);
      expect(service.getUserById('123')).toEqual(mockUser);
    });

    it('should filter active users', () => {
      const activeUser = { ...mockUser, status: 'active' as const };
      const inactiveUser = { ...mockUser, id: '456', status: 'inactive' as const };

      service['updateUserInState'](activeUser);
      service['updateUserInState'](inactiveUser);

      expect(service.activeUsers()).toEqual([activeUser]);
    });
  });
});

๐Ÿ”ง Development Workflow

Angular CLI Configuration

// angular.json
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "my-angular-app": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "style": "scss",
          "standalone": true,
          "changeDetection": "OnPush"
        },
        "@schematics/angular:directive": {
          "standalone": true
        },
        "@schematics/angular:pipe": {
          "standalone": true
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser-esbuild",
          "options": {
            "outputPath": "dist/my-angular-app",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": [
              "zone.js"
            ],
            "tsConfig": "tsconfig.app.json",
            "inlineStyleLanguage": "scss",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "@angular/material/prebuilt-themes/indigo-pink.css",
              "src/styles.scss"
            ],
            "scripts": []
          },
          "configurations": {
            "production": {
              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "500kb",
                  "maximumError": "1mb"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "2kb",
                  "maximumError": "4kb"
                }
              ],
              "outputHashing": "all"
            },
            "development": {
              "buildOptimizer": false,
              "optimization": false,
              "vendorChunk": true,
              "extractLicenses": false,
              "sourceMap": true,
              "namedChunks": true
            }
          },
          "defaultConfiguration": "production"
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "configurations": {
            "production": {
              "buildTarget": "my-angular-app:build:production"
            },
            "development": {
              "buildTarget": "my-angular-app:build:development"
            }
          },
          "defaultConfiguration": "development"
        },
        "test": {
          "builder": "@angular-devkit/build-angular:karma",
          "options": {
            "polyfills": [
              "zone.js",
              "zone.js/testing"
            ],
            "tsConfig": "tsconfig.spec.json",
            "inlineStyleLanguage": "scss",
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "@angular/material/prebuilt-themes/indigo-pink.css",
              "src/styles.scss"
            ],
            "scripts": []
          }
        }
      }
    }
  },
  "cli": {
    "analytics": false
  }
}

Development Commands

# Create new Angular project
ng new my-angular-app --routing --style=scss --standalone

# Development server
ng serve
ng serve --port 4200 --host 0.0.0.0

# Build for production
ng build --configuration production

# Run tests
ng test
ng test --code-coverage
ng test --watch=false --browsers=ChromeHeadless

# Run e2e tests
ng e2e

# Generate components/services
ng generate component user-profile --standalone
ng generate service services/user
ng generate guard guards/auth --functional

# Analyze bundle size
ng build --stats-json
npx webpack-bundle-analyzer dist/my-angular-app/stats.json

# Lint and format
ng lint
ng lint --fix

I specialize in building modern, performant Angular applications using Angular 17+ features like signals, standalone components, and the new control flow syntax. I'll help you create scalable applications with proper architecture, comprehensive testing, and performance optimization.