Phase 10 — Angular Signals

Chapter 1 — Angular Signals — Complete Deep Dive


1.1 — Why Signals Exist

Before Signals, Angular used a system called Zone.js to detect changes. Zone.js works by monkey-patching every asynchronous browser API — setTimeout, setInterval, Promise, fetch, event listeners — and notifying Angular after every single one of them completes.

This works but it is blunt. Angular has no idea what actually changed. It just knows something happened. So it checks everything — the entire component tree — looking for what might have changed. On a large app with hundreds of components, this means hundreds of checks after every click, every keypress, every timer tick.

Signals are Angular's answer to this. A signal is a reactive value that knows who is reading it. When a signal's value changes, Angular knows exactly which parts of the screen depend on it — and updates only those parts. Not the whole tree. Not random components. Just the specific DOM nodes that read that specific signal.

This is called fine-grained reactivity. It is faster, more predictable, and easier to reason about than the old Zone.js approach.


1.2 — signal() — Creating a Reactive Value

You create a signal with the signal() function:

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

@Component({
  selector: 'app-counter',
  imports: [],
  template: `
    <div class="counter">
      <button (click)="decrement()">-</button>
      <span class="count">{{ count() }}</span>
      <button (click)="increment()">+</button>
    </div>
  `
})
export class Counter {

  count = signal(0);          // initial value is 0
  name = signal('Angular');   // initial value is a string
  isVisible = signal(true);   // initial value is a boolean

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

  decrement(): void {
    this.count.update(current => current - 1);
  }
}

Reading a signal always requires calling it like a function with (). In the template: {{ count() }}. In TypeScript: this.count().

Updating a signal:

.set(newValue) — replaces the current value with a completely new value:

this.count.set(10);
this.name.set('TypeScript');
this.isVisible.set(false);

.update(fn) — updates based on the current value. The function receives the current value and returns the new value:

this.count.update(current => current + 1);
this.name.update(name => name.toUpperCase());
this.items.update(list => [...list, newItem]);

The difference between set and update is that set does not care about the previous value, while update uses it. When you need the current value to compute the next value — use update. When you are setting a completely new value regardless of what was there — use set.


1.3 — computed() — Derived Reactive Values

A computed signal is a read-only signal whose value is automatically derived from other signals. Whenever any signal it reads changes, the computed value automatically recalculates.

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

@Component({
  selector: 'app-shopping-cart',
  imports: [],
  template: `
    <div class="cart">
      <h2>Cart Summary</h2>
      <p>Items: {{ totalItems() }}</p>
      <p>Subtotal: ₹{{ subtotal() }}</p>
      <p>Tax (18%): ₹{{ tax() }}</p>
      <p class="total">Total: ₹{{ grandTotal() }}</p>

      <button (click)="addItem()">Add Item</button>
      <button (click)="clearCart()">Clear</button>
    </div>
  `
})
export class ShoppingCart {

  items = signal<{ name: string; price: number }[]>([
    { name: 'Keyboard', price: 2500 },
    { name: 'Mouse', price: 800 }
  ]);

  // These all automatically recalculate when items changes
  totalItems = computed(() => this.items().length);

  subtotal = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );

  tax = computed(() => Math.round(this.subtotal() * 0.18));

  grandTotal = computed(() => this.subtotal() + this.tax());

  // Computed signals can depend on other computed signals
  grandTotalFormatted = computed(() =>
    `₹${this.grandTotal().toLocaleString('en-IN')}`
  );

  addItem(): void {
    this.items.update(list => [...list, { name: 'New Item', price: 1000 }]);
    // When items changes, ALL computed signals recalculate automatically
  }

  clearCart(): void {
    this.items.set([]);
  }
}

Computed signals are lazy and memoized. They only recalculate when their dependencies change. If nothing changed, they return the cached value without running the function again. This makes them very efficient even if the template reads them many times.

Computed signals are always read-only. You cannot call .set() or .update() on them — they always derive their value from their dependencies.


1.4 — effect() — Reacting to Signal Changes

An effect runs a side-effect function whenever any signal it reads changes. Side effects are things that happen outside the reactive data flow — logging, modifying the DOM directly, saving to localStorage, starting an animation.

import { Component, signal, computed, effect, inject } from '@angular/core';

@Component({
  selector: 'app-theme-demo',
  imports: [],
  template: `
    <div class="theme-demo">
      <h2>Current theme: {{ theme() }}</h2>
      <p>Last changed: {{ lastChanged() }}</p>
      <button (click)="setTheme('light')">Light</button>
      <button (click)="setTheme('dark')">Dark</button>
      <button (click)="setTheme('system')">System</button>
    </div>
  `
})
export class ThemeDemo {

  theme = signal<'light' | 'dark' | 'system'>('light');
  lastChanged = signal<string>('Never');

  constructor() {
    // effect() must be created in a constructor or injection context
    effect(() => {
      // This function runs whenever theme() changes
      const currentTheme = this.theme();  // reading the signal registers it as a dependency

      // Side effect: update the DOM
      document.body.setAttribute('data-theme', currentTheme);

      // Side effect: save to localStorage
      localStorage.setItem('theme', currentTheme);

      // Side effect: update another signal (needs allowSignalWrites option)
      console.log('Theme changed to:', currentTheme);
    });
  }

  setTheme(theme: 'light' | 'dark' | 'system'): void {
    this.theme.set(theme);
    this.lastChanged.set(new Date().toLocaleTimeString());
  }
}

Effects automatically track their dependencies — any signal read inside the effect function becomes a dependency. When any of those signals change, the effect re-runs.

Effects are created in the constructor (or any injection context). They automatically clean up when the component is destroyed.

When to use effect: DOM manipulation that cannot be done with data binding, integration with third-party libraries that need to react to data changes, saving data to localStorage or IndexedDB, analytics tracking.

When NOT to use effect: Deriving values from signals (use computed instead), anything that can be done with computed or template bindings.


1.5 — Input Signals — Modern @Input()

In modern Angular, you can declare @Input() properties as signals using input():

import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-product-card',
  imports: [],
  template: `
    <div class="card">
      <h3>{{ name() }}</h3>
      <p class="price">₹{{ price() }}</p>
      <span class="status" [class.in-stock]="inStock()" [class.out-of-stock]="!inStock()">
        {{ stockStatus() }}
      </span>
    </div>
  `
})
export class ProductCard {

  // Signal-based inputs
  name = input.required<string>();             // required input
  price = input.required<number>();            // required input
  inStock = input<boolean>(true);              // optional input with default

  // You can use computed() with input signals
  stockStatus = computed(() => this.inStock() ? 'In Stock' : 'Out of Stock');

  // The input values are read with () just like regular signals
}

Signal inputs have several advantages over decorator-based @Input():

  • They are readable anywhere using ()
  • You can use computed() to derive values from them
  • They work with change detection more efficiently
  • No ngOnChanges needed — just use computed() to react to changes

The parent uses them exactly the same way:

<app-product-card
  [name]="product.name"
  [price]="product.price"
  [inStock]="product.inStock">
</app-product-card>

1.6 — output() — Modern @Output()

Similarly, output() is the signal-based replacement for @Output():

import { Component, output } from '@angular/core';

@Component({
  selector: 'app-rating',
  imports: [],
  template: `
    <div class="rating">
      @for (star of stars; track star) {
        <span
          class="star"
          [class.filled]="star <= currentRating"
          (click)="setRating(star)">
          ★
        </span>
      }
    </div>
  `
})
export class Rating {

  // Signal-based output
  ratingChanged = output<number>();

  stars = [1, 2, 3, 4, 5];
  currentRating = 0;

  setRating(rating: number): void {
    this.currentRating = rating;
    this.ratingChanged.emit(rating);  // same .emit() as EventEmitter
  }
}

Parent usage is the same:

<app-rating (ratingChanged)="onRatingChanged($event)"></app-rating>

1.7 — model() — Two-Way Signal Binding

model() creates a two-way bindable signal. It is the signal-based equivalent of [(ngModel)] for component inputs:

import { Component, model } from '@angular/core';

@Component({
  selector: 'app-toggle',
  imports: [],
  template: `
    <div class="toggle" [class.on]="checked()" (click)="toggle()">
      <div class="knob"></div>
    </div>
  `,
  styles: [`
    .toggle { width: 52px; height: 28px; background: #ccc; border-radius: 14px;
              cursor: pointer; position: relative; transition: background 0.2s; }
    .toggle.on { background: #0070f3; }
    .knob { width: 24px; height: 24px; background: white; border-radius: 50%;
            position: absolute; top: 2px; left: 2px; transition: transform 0.2s; }
    .toggle.on .knob { transform: translateX(24px); }
  `]
})
export class Toggle {

  // model() creates a two-way bindable signal
  checked = model<boolean>(false);

  toggle(): void {
    this.checked.update(val => !val);
  }
}

The parent uses it with the two-way binding syntax:

<app-toggle [(checked)]="isEnabled"></app-toggle>
<p>Enabled: {{ isEnabled() }}</p>

When the user clicks the toggle, checked updates inside the component AND isEnabled in the parent updates simultaneously.


1.8 — Signals vs RxJS — When to Use Which

This is a question many developers ask. The answer is they serve different purposes and you will use both:

Use Signals for:

  • Component state — loading flags, local data, UI state
  • Shared service state — cart, auth, theme
  • Derived values — anything computed from other state
  • @Input() and @Output() in modern components

Use RxJS for:

  • HTTP requests — HttpClient returns Observables
  • Routing events — Router exposes Observables
  • Complex async workflows — debouncing, retrying, combining multiple streams
  • Event streams — form valueChanges, DOM events
  • Time-based operations — intervals, debounce, throttle

Often you will use both together. A service might use HttpClient (Observable) to fetch data and then store it in a signal:

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

  private http = inject(HttpClient);

  products = signal<Product[]>([]);
  isLoading = signal(false);

  loadProducts(): void {
    this.isLoading.set(true);
    this.http.get<Product[]>('/api/products').subscribe({
      next: data => {
        this.products.set(data);    // store in signal
        this.isLoading.set(false);
      },
      error: () => this.isLoading.set(false)
    });
  }
}

RxJS handles the async HTTP call. Signals hold the resulting state. Components read the signals. Clean separation.


Chapter 2 — Pipes — Complete Guide


2.1 — What are Pipes?

Pipes transform data for display in templates. They take a value, apply a transformation, and return the transformed value. The transformation happens only for display — the original data in your component is unchanged.

{{ price }}                    → 50000
{{ price | currency:'INR' }}   → ₹50,000.00

{{ date }}                     → Mon Jan 06 2025 00:00:00 GMT+0530
{{ date | date:'dd MMM yyyy' }} → 06 Jan 2025

{{ name }}                     → rahul sharma
{{ name | titlecase }}         → Rahul Sharma

2.2 — All Built-in Pipes

DatePipe — format dates

today = new Date();
releaseDate = new Date('2025-01-15');
{{ today | date }}                          → Jan 6, 2025
{{ today | date:'short' }}                  → 1/6/25, 12:00 AM
{{ today | date:'longDate' }}               → January 6, 2025
{{ today | date:'dd/MM/yyyy' }}             → 06/01/2025
{{ today | date:'dd MMM yyyy, hh:mm a' }}   → 06 Jan 2025, 12:00 AM
{{ today | date:'EEEE, MMMM d, y' }}        → Monday, January 6, 2025

Import: import { DatePipe } from '@angular/common'


CurrencyPipe — format money

{{ 50000 | currency }}                    → $50,000.00  (default USD)
{{ 50000 | currency:'INR' }}              → ₹50,000.00
{{ 50000 | currency:'INR':'code' }}       → INR 50,000.00
{{ 50000 | currency:'INR':'symbol':'1.0-0' }} → ₹50,000

Import: import { CurrencyPipe } from '@angular/common'


DecimalPipe — format numbers

{{ 3.14159 | number:'1.2-2' }}   → 3.14      (at least 1 digit before decimal, 2 after)
{{ 3.14159 | number:'1.4-4' }}   → 3.1416    (4 decimal places)
{{ 12345.6 | number }}           → 12,345.6
{{ 0.75 | percent }}             → 75%
{{ 0.75 | percent:'1.1-1' }}     → 75.0%

Import: import { DecimalPipe, PercentPipe } from '@angular/common'


String case pipes

{{ 'hello world' | uppercase }}   → HELLO WORLD
{{ 'HELLO WORLD' | lowercase }}   → hello world
{{ 'hello world' | titlecase }}   → Hello World

Import: import { UpperCasePipe, LowerCasePipe, TitleCasePipe } from '@angular/common'


SlicePipe — slice arrays or strings

{{ 'Hello Angular' | slice:6 }}        → Angular
{{ 'Hello Angular' | slice:6:9 }}      → Ang
{{ [1,2,3,4,5] | slice:1:4 }}          → [2, 3, 4]

Import: import { SlicePipe } from '@angular/common'


JsonPipe — display objects as JSON (debugging)

{{ user | json }}
→ { "id": 1, "name": "Rahul", "email": "rahul@example.com" }

Import: import { JsonPipe } from '@angular/common'


AsyncPipe — subscribe to Observables and Promises in templates

{{ data$ | async }}

We covered this deeply in Phase 9. Import: import { AsyncPipe } from '@angular/common'


Chaining pipes

You can chain multiple pipes:

{{ name | lowercase | titlecase }}
{{ today | date:'yyyy-MM-dd' | uppercase }}
{{ amount | currency:'INR' | slice:0:8 }}

2.3 — Building a Custom Pipe

Generate a pipe:

ng g p pipes/truncate --skip-tests

src/app/pipes/truncate.ts:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate'
})
export class Truncate implements PipeTransform {

  transform(value: string, limit: number = 100, ellipsis: string = '...'): string {
    if (!value) return '';
    if (value.length <= limit) return value;
    return value.substring(0, limit) + ellipsis;
  }
}

Using it:

import { Truncate } from '../../pipes/truncate';

@Component({
  imports: [Truncate],
  template: `
    <p>{{ longText | truncate }}</p>
    <p>{{ longText | truncate:50 }}</p>
    <p>{{ longText | truncate:30:' [read more]' }}</p>
  `
})
export class Article {
  longText = 'This is a very long piece of text that should be truncated after a certain number of characters.';
}

2.4 — A Time Ago Pipe

A practical custom pipe that shows human-readable time differences:

src/app/pipes/time-ago.ts:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'timeAgo'
})
export class TimeAgo implements PipeTransform {

  transform(value: Date | string | number): string {
    const date = new Date(value);
    const now = new Date();
    const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);

    if (seconds < 60) {
      return 'just now';
    }

    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) {
      return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
    }

    const hours = Math.floor(minutes / 60);
    if (hours < 24) {
      return `${hours} hour${hours > 1 ? 's' : ''} ago`;
    }

    const days = Math.floor(hours / 24);
    if (days < 30) {
      return `${days} day${days > 1 ? 's' : ''} ago`;
    }

    const months = Math.floor(days / 30);
    if (months < 12) {
      return `${months} month${months > 1 ? 's' : ''} ago`;
    }

    const years = Math.floor(months / 12);
    return `${years} year${years > 1 ? 's' : ''} ago`;
  }
}
<p>Posted: {{ post.createdAt | timeAgo }}</p>
<!-- Output: Posted: 3 hours ago -->

2.5 — Pure vs Impure Pipes

Pipes are pure by default. A pure pipe only runs when Angular detects a change in the input value's reference. If you pass an array and push an item to it, the reference is the same — the pure pipe does not re-run.

An impure pipe runs on every change detection cycle regardless of whether the input changed. This is less performant but sometimes necessary.

@Pipe({
  name: 'filterList',
  pure: false     // ← impure — runs on every change detection cycle
})
export class FilterList implements PipeTransform {
  transform(items: any[], filterText: string): any[] {
    if (!filterText) return items;
    return items.filter(item =>
      item.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }
}

Use impure pipes sparingly. They can hurt performance because they run so frequently. In most cases, it is better to do filtering/sorting in the component's TypeScript code and store the result in a signal.


Chapter 3 — Performance Optimization


3.1 — OnPush Change Detection

We covered this in Phase 3 but let's revisit it properly here.

By default, Angular checks a component for changes whenever anything happens anywhere in the application. With OnPush, Angular only checks a component when:

  1. An @Input() property receives a new object reference
  2. An event originates from the component or its children
  3. An Observable subscribed with async pipe emits
  4. A signal read in the template changes
  5. You manually call markForCheck()
import { Component, input, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product-card',
  imports: [],
  templateUrl: './product-card.html',
  changeDetection: ChangeDetectionStrategy.OnPush  // ← add this
})
export class ProductCard {
  product = input.required<Product>();
}

The rule for OnPush is: always pass new references, never mutate. Instead of:

// WRONG with OnPush — mutating the array
this.products.push(newProduct);

// RIGHT with OnPush — creating a new array
this.products = [...this.products, newProduct];

When you use Signals, Angular's change detection automatically becomes granular and efficient — signals work perfectly with OnPush because Angular knows exactly which signals a component reads.


3.2 — @defer — Deferrable Views

@defer is one of the most powerful Angular features. It lets you defer the loading of parts of your template — both the rendering and the JavaScript download.

<!-- This entire block is deferred — not rendered or downloaded until needed -->
@defer {
  <app-heavy-chart></app-heavy-chart>
}

By default, @defer loads when the browser becomes idle. But you can control exactly when it loads with triggers:

on viewport — load when the element would scroll into view:

@defer (on viewport) {
  <app-comments-section></app-comments-section>
}
@placeholder {
  <div class="comments-placeholder">Comments loading...</div>
}

on interaction — load when the user interacts with the placeholder:

@defer (on interaction) {
  <app-rich-text-editor></app-rich-text-editor>
}
@placeholder {
  <div class="editor-placeholder">Click to start editing...</div>
}

on timer — load after a delay:

@defer (on timer(3s)) {
  <app-newsletter-popup></app-newsletter-popup>
}

when condition — load when a condition becomes true:

@defer (when isLoggedIn()) {
  <app-premium-content></app-premium-content>
}

@loading, @placeholder, @error blocks:

@defer (on viewport; prefetch on idle) {
  <app-analytics-chart [data]="chartData"></app-analytics-chart>
} @loading (minimum 500ms) {
  <div class="loading-skeleton">
    <div class="skeleton-bar"></div>
    <div class="skeleton-bar short"></div>
  </div>
} @placeholder {
  <div class="chart-placeholder">📊 Chart will load when visible</div>
} @error {
  <div class="error-state">Failed to load chart</div>
}

@placeholder — shows before the deferred content starts loading @loading — shows while loading (with optional minimum to prevent flash) @error — shows if loading fails

prefetch on idle — downloads the JavaScript in the background when the browser is idle, before the user scrolls to it. The download is ready so when they scroll it renders instantly.

@defer is powerful for optimizing initial load time. Move everything below the fold into @defer blocks and your initial bundle shrinks dramatically.


3.3 — trackBy in @for

We mentioned this in Phase 4 but it is a genuine performance optimization worth revisiting here.

When Angular renders a @for list and the data changes, without track it destroys and recreates every DOM element. With a proper track expression, Angular figures out which items are new, which moved, and which were removed — and updates only those.

<!-- Without track — Angular recreates all DOM elements when list changes -->
@for (product of products; track $index) {
  <app-product-card [product]="product"></app-product-card>
}

<!-- With unique ID track — Angular updates only what changed -->
@for (product of products; track product.id) {
  <app-product-card [product]="product"></app-product-card>
}

For a list of 1000 items where only 2 changed, tracking by unique ID means 2 DOM updates instead of 1000 destructions and recreations. The visual difference is dramatic.

Always track by a unique, stable identifier — database ID, UUID. Never track $index unless the list is purely static — tracking by index gives Angular no useful information about which items are actually new or moved.


3.4 — Lazy Loading Routes

We covered this in Phase 6 but it deserves a mention here as a performance optimization. Lazy loading splits your application into separate JavaScript chunks. Each chunk is only downloaded when needed.

export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('./admin/admin').then(m => m.Admin)
  }
];

Every page that uses loadComponent is a separate chunk. Users only download the code for pages they visit. This is the single biggest performance win for large applications — users get faster initial load times because they are not downloading code for the admin panel on their first visit to the home page.


3.5 — Image Optimization with NgOptimizedImage

Angular provides NgOptimizedImage for optimizing images. It handles lazy loading, proper sizing, and priority loading:

import { NgOptimizedImage } from '@angular/common';

@Component({
  selector: 'app-hero',
  imports: [NgOptimizedImage],
  template: `
    <!-- Priority image — loads immediately (above the fold) -->
    <img
      ngSrc="/hero-image.jpg"
      alt="Hero"
      width="1200"
      height="600"
      priority>

    <!-- Non-priority images load lazily -->
    <img
      ngSrc="/product-photo.jpg"
      alt="Product"
      width="400"
      height="400">
  `
})
export class Hero { }

ngSrc instead of src — this tells Angular to optimize the image. width and height are required to prevent layout shift. priority marks the image as important — it gets preloaded.


Chapter 4 — Angular Animations


4.1 — Setting Up Animations

Add provideAnimations() to app.config.ts:

import { provideAnimations } from '@angular/platform-browser/animations';

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

4.2 — Basic Animation Concepts

Angular animations use five building blocks:

trigger — defines an animation. Has a name and contains states and transitions.

state — defines the CSS styles for a particular named state.

transition — defines how to animate between two states.

animate — defines the duration, easing, and delay of an animation.

style — defines CSS styles within an animation step.


4.3 — A Complete Animation Example

src/app/pages/animated/animated.ts:

import { Component, signal } from '@angular/core';
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';

@Component({
  selector: 'app-animated',
  imports: [],
  templateUrl: './animated.html',
  styleUrl: './animated.css',
  animations: [

    // Toggle visibility with fade + slide
    trigger('fadeSlide', [
      state('void', style({ opacity: 0, transform: 'translateY(-20px)' })),
      state('*', style({ opacity: 1, transform: 'translateY(0)' })),
      transition(':enter', [
        animate('300ms ease-out')
      ]),
      transition(':leave', [
        animate('200ms ease-in', style({ opacity: 0, transform: 'translateY(-20px)' }))
      ])
    ]),

    // Expand and collapse height
    trigger('expandCollapse', [
      state('collapsed', style({ height: '0', overflow: 'hidden', opacity: 0 })),
      state('expanded', style({ height: '*', overflow: 'hidden', opacity: 1 })),
      transition('collapsed <=> expanded', [
        animate('300ms ease-in-out')
      ])
    ]),

    // List items stagger animation
    trigger('listAnimation', [
      transition('* => *', [
        query(':enter', [
          style({ opacity: 0, transform: 'translateX(-20px)' }),
          stagger('60ms', [
            animate('400ms ease-out', style({ opacity: 1, transform: 'translateX(0)' }))
          ])
        ], { optional: true })
      ])
    ])

  ]
})
export class Animated {

  isVisible = signal(true);
  isExpanded = signal(false);

  items = signal(['Item One', 'Item Two', 'Item Three']);

  toggleVisibility(): void {
    this.isVisible.update(v => !v);
  }

  toggleExpand(): void {
    this.isExpanded.update(v => !v);
  }

  addItem(): void {
    this.items.update(list => [...list, `Item ${list.length + 1}`]);
  }

  removeFirst(): void {
    this.items.update(list => list.slice(1));
  }
}

src/app/pages/animated/animated.html:

<div class="animations-demo">
  <h1>Angular Animations</h1>

  <section class="demo-section">
    <h2>Fade + Slide In/Out</h2>
    <button (click)="toggleVisibility()">
      {{ isVisible() ? 'Hide' : 'Show' }}
    </button>

    @if (isVisible()) {
      <div class="demo-box" @fadeSlide>
        I animate in and out!
      </div>
    }
  </section>

  <section class="demo-section">
    <h2>Expand / Collapse</h2>
    <button class="accordion-header" (click)="toggleExpand()">
      FAQ: What is Angular?
      <span>{{ isExpanded() ? '▲' : '▼' }}</span>
    </button>

    <div class="accordion-content"
         [@expandCollapse]="isExpanded() ? 'expanded' : 'collapsed'">
      <p>
        Angular is a powerful front-end framework built by Google.
        It provides a complete solution for building web applications
        with components, services, routing, and reactive forms.
      </p>
    </div>
  </section>

  <section class="demo-section">
    <h2>Staggered List</h2>
    <div class="list-controls">
      <button (click)="addItem()">Add Item</button>
      <button (click)="removeFirst()">Remove First</button>
    </div>

    <ul [@listAnimation]="items().length">
      @for (item of items(); track item) {
        <li>{{ item }}</li>
      }
    </ul>
  </section>
</div>

src/app/pages/animated/animated.css:

.animations-demo {
  max-width: 700px;
  margin: 0 auto;
  padding: 40px 24px;
}

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

.demo-section {
  background: white;
  border-radius: 12px;
  padding: 28px;
  margin-bottom: 24px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}

.demo-section h2 {
  font-size: 18px;
  font-weight: 700;
  color: #1a1a2e;
  margin-bottom: 16px;
}

button {
  background: #0070f3;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 14px;
  cursor: pointer;
  margin-right: 8px;
  transition: background 0.2s;
}

button:hover { background: #005ac1; }

.demo-box {
  background: #f0f7ff;
  border: 2px solid #0070f3;
  border-radius: 8px;
  padding: 20px;
  margin-top: 16px;
  color: #0070f3;
  font-weight: 600;
}

.accordion-header {
  width: 100%;
  background: #f5f7fa;
  color: #1a1a2e;
  border: 1px solid #e0e0e0;
  padding: 14px 18px;
  border-radius: 8px;
  display: flex;
  justify-content: space-between;
  text-align: left;
}

.accordion-content {
  overflow: hidden;
}

.accordion-content p {
  padding: 16px 18px;
  color: #555;
  line-height: 1.7;
  border: 1px solid #e0e0e0;
  border-top: none;
  border-radius: 0 0 8px 8px;
}

.list-controls { margin-bottom: 16px; }

ul {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

li {
  background: #f5f7fa;
  padding: 12px 16px;
  border-radius: 8px;
  color: #1a1a2e;
  font-size: 15px;
}

Chapter 5 — Testing with Vitest


5.1 — Why Testing Matters

Testing might feel like extra work but it pays off enormously. Tests:

  • Catch bugs before users do
  • Make refactoring safe — change code and your tests tell you if you broke something
  • Document how code is supposed to work
  • Give you confidence to add features without fear of breaking existing ones

Angular uses Vitest as its test runner. Vitest is fast, modern, and has a great developer experience.


5.2 — Running Tests

ng test

Vitest runs all .spec.ts files and shows you pass/fail results. It watches for file changes and re-runs relevant tests automatically.


5.3 — Testing a Service

Let's test the Cart service:

src/app/services/cart.spec.ts:

import { TestBed } from '@angular/core/testing';
import { Cart } from './cart';

describe('Cart Service', () => {

  let service: Cart;

  beforeEach(() => {
    // TestBed creates an Angular testing module
    TestBed.configureTestingModule({});
    service = TestBed.inject(Cart);
  });

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

  it('should start with empty cart', () => {
    expect(service.cartItems().length).toBe(0);
    expect(service.totalItems()).toBe(0);
    expect(service.totalPrice()).toBe(0);
    expect(service.isEmpty()).toBe(true);
  });

  it('should add an item to the cart', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });

    expect(service.cartItems().length).toBe(1);
    expect(service.totalItems()).toBe(1);
    expect(service.totalPrice()).toBe(2500);
    expect(service.isEmpty()).toBe(false);
  });

  it('should increase quantity when same item is added twice', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });

    expect(service.cartItems().length).toBe(1);    // still 1 unique item
    expect(service.totalItems()).toBe(2);            // but quantity is 2
    expect(service.totalPrice()).toBe(5000);
  });

  it('should add different items as separate entries', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.addItem({ id: 2, name: 'Mouse', price: 800, imageUrl: '' });

    expect(service.cartItems().length).toBe(2);
    expect(service.totalItems()).toBe(2);
    expect(service.totalPrice()).toBe(3300);
  });

  it('should remove an item', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.removeItem(1);

    expect(service.cartItems().length).toBe(0);
    expect(service.isEmpty()).toBe(true);
  });

  it('should increase quantity', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.increaseQuantity(1);

    expect(service.totalItems()).toBe(2);
    expect(service.totalPrice()).toBe(5000);
  });

  it('should decrease quantity', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.decreaseQuantity(1);

    expect(service.totalItems()).toBe(1);
  });

  it('should remove item when quantity decreases to zero', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.decreaseQuantity(1);

    expect(service.cartItems().length).toBe(0);
  });

  it('should clear entire cart', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });
    service.addItem({ id: 2, name: 'Mouse', price: 800, imageUrl: '' });
    service.clearCart();

    expect(service.cartItems().length).toBe(0);
    expect(service.totalItems()).toBe(0);
    expect(service.totalPrice()).toBe(0);
  });

  it('should correctly report if item is in cart', () => {
    service.addItem({ id: 1, name: 'Keyboard', price: 2500, imageUrl: '' });

    expect(service.isInCart(1)).toBe(true);
    expect(service.isInCart(2)).toBe(false);
  });
});

describe groups related tests. it (or test) defines a single test. beforeEach runs before each test — here it creates a fresh service instance so tests do not interfere with each other.

expect(value).toBe(expected) — strict equality check (like ===). expect(value).toBeTruthy() — value is truthy. expect(value).toBeFalsy() — value is falsy. expect(value).toEqual(expected) — deep equality (for objects and arrays). expect(array).toContain(item) — array contains item.


5.4 — Testing a Component

src/app/components/counter/counter.spec.ts:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Counter } from './counter';

describe('Counter Component', () => {

  let component: Counter;
  let fixture: ComponentFixture<Counter>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [Counter]   // import the standalone component
    }).compileComponents();

    fixture = TestBed.createComponent(Counter);
    component = fixture.componentInstance;
    fixture.detectChanges();  // trigger initial change detection
  });

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

  it('should start with count of 0', () => {
    expect(component.count()).toBe(0);
  });

  it('should increment count', () => {
    component.increment();
    expect(component.count()).toBe(1);

    component.increment();
    expect(component.count()).toBe(2);
  });

  it('should decrement count', () => {
    component.increment();
    component.decrement();
    expect(component.count()).toBe(0);
  });

  it('should display the count in the template', () => {
    fixture.detectChanges();

    // Get the element that shows the count
    const countElement: HTMLElement = fixture.nativeElement.querySelector('.count');
    expect(countElement.textContent).toContain('0');

    // Increment and check the DOM updates
    component.increment();
    fixture.detectChanges();
    expect(countElement.textContent).toContain('1');
  });

  it('should respond to button click', () => {
    const buttons = fixture.nativeElement.querySelectorAll('button');
    const incrementButton = buttons[1];  // assuming +/count/- layout

    incrementButton.click();
    fixture.detectChanges();

    expect(component.count()).toBe(1);
  });
});

TestBed.createComponent(Counter) creates an instance of the component in a testing environment. fixture.detectChanges() triggers Angular's change detection so the template renders. fixture.nativeElement is the component's actual DOM element.


5.5 — Testing a Pipe

import { Truncate } from './truncate';

describe('Truncate Pipe', () => {

  let pipe: Truncate;

  beforeEach(() => {
    pipe = new Truncate();
  });

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

  it('should return empty string for null or undefined', () => {
    expect(pipe.transform('', 50)).toBe('');
  });

  it('should not truncate strings shorter than limit', () => {
    expect(pipe.transform('Hello', 10)).toBe('Hello');
    expect(pipe.transform('Hello', 5)).toBe('Hello');
  });

  it('should truncate strings longer than limit', () => {
    const result = pipe.transform('Hello World Angular', 10);
    expect(result).toBe('Hello Worl...');
  });

  it('should use custom ellipsis', () => {
    const result = pipe.transform('Hello World Angular', 10, ' [more]');
    expect(result).toBe('Hello Worl [more]');
  });
});

Pipes are pure TypeScript classes with a transform method — they are the easiest things to test because you just create an instance and call the method.


Chapter 6 — Building for Production and Deployment


6.1 — The Production Build

ng build

or specifically for production:

ng build --configuration=production

The production build:

  • Minifies all JavaScript and CSS — removes whitespace, shortens variable names
  • Tree-shakes — removes unused code
  • Ahead-of-Time (AOT) compilation — Angular templates are compiled to JavaScript at build time instead of in the browser
  • Output hashing — adds a hash to filenames so browsers know when to download new versions
  • Source maps — optional, for debugging production issues

The output goes to dist/your-app-name/browser/.


6.2 — Analyzing Bundle Size

To understand what is making your bundle large:

ng build --stats-json

Then install and run webpack-bundle-analyzer:

npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/your-app/browser/stats.json

This opens a visual interactive treemap showing exactly what is in your bundle and how large each piece is. Use this to identify large dependencies you might be able to replace or lazy load.


6.3 — Deploying to Netlify

Netlify is one of the easiest deployment platforms for Angular apps.

  1. Build your app: ng build
  2. Go to netlify.com and sign in
  3. Drag and drop the dist/your-app/browser/ folder onto Netlify
  4. Your app is live in seconds

For automatic deployment from GitHub:

  1. Push your project to GitHub
  2. In Netlify, click "New site from Git"
  3. Connect your GitHub repository
  4. Build command: ng build
  5. Publish directory: dist/your-app/browser
  6. Every push to main automatically deploys

One important thing for Angular apps on Netlify — add a _redirects file inside your public/ folder:

public/_redirects:

/*    /index.html    200

This tells Netlify to serve index.html for all routes. Without this, if a user refreshes on /products/42, Netlify tries to find a file at that path, fails, and returns a 404. With this redirect rule, Netlify always serves index.html and Angular's router takes over.


6.4 — Deploying to Vercel

Vercel is another excellent platform:

  1. Install Vercel CLI: npm install -g vercel
  2. In your project: vercel
  3. Follow the prompts — set framework to Angular

Or connect directly to GitHub via vercel.com.

Add a vercel.json to your project root:

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Same reason as the Netlify redirect — all routes should serve index.html.


6.5 — Deploying to Firebase Hosting

Firebase Hosting is Google's hosting platform and has excellent Angular integration:

npm install -g firebase-tools
firebase login
firebase init hosting

When asked:

  • Public directory: dist/your-app/browser
  • Configure as single-page app: Yes
  • Automatic builds with GitHub: Optional

Deploy:

ng build
firebase deploy

Chapter 7 — Putting It All Together — The Final Project


Let's build a complete Dev Blog application that showcases everything from all 10 phases — Signals, Pipes, Animations, HTTP with interceptors, Routing with guards, Reactive Forms, Services, RxJS, OnPush change detection, and @defer.


Project Setup

ng new dev-blog --style=css
cd dev-blog

ng g s services/blog --skip-tests
ng g s services/auth --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/auth --skip-tests
ng g p pipes/time-ago --skip-tests
ng g p pipes/truncate --skip-tests
ng g c components/navbar --skip-tests
ng g c components/toast --skip-tests
ng g c components/loader --skip-tests
ng g c pages/home --skip-tests
ng g c pages/blog --skip-tests
ng g c pages/post-detail --skip-tests
ng g c pages/login --skip-tests
ng g c pages/write --skip-tests
ng g c pages/not-found --skip-tests
ng g g guards/auth --skip-tests

App Config

src/app/app.config.ts:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { routes } from './app.routes';
import { loadingInterceptor } from './interceptors/loading';
import { authInterceptor } from './interceptors/auth';

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

Services

src/app/services/auth.ts:

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

export interface AuthUser {
  id: number;
  name: string;
  email: string;
  avatar: string;
  role: 'admin' | 'author' | 'reader';
}

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

  private currentUser = signal<AuthUser | null>(null);
  private token = signal<string | null>(null);

  readonly user = this.currentUser.asReadonly();
  readonly isLoggedIn = computed(() => this.currentUser() !== null);
  readonly isAuthor = computed(() =>
    this.currentUser()?.role === 'admin' || this.currentUser()?.role === 'author'
  );
  readonly userName = computed(() => this.currentUser()?.name ?? 'Guest');
  readonly userInitial = computed(() => this.currentUser()?.name?.[0]?.toUpperCase() ?? 'G');

  constructor() {
    // Persist auth state to localStorage
    effect(() => {
      const user = this.currentUser();
      const tok = this.token();
      if (user && tok) {
        localStorage.setItem('auth_user', JSON.stringify(user));
        localStorage.setItem('auth_token', tok);
      } else {
        localStorage.removeItem('auth_user');
        localStorage.removeItem('auth_token');
      }
    });

    // Restore from localStorage on app start
    const savedUser = localStorage.getItem('auth_user');
    const savedToken = localStorage.getItem('auth_token');
    if (savedUser && savedToken) {
      this.currentUser.set(JSON.parse(savedUser));
      this.token.set(savedToken);
    }
  }

  getToken(): string | null {
    return this.token();
  }

  login(email: string, password: string): boolean {
    const mockUsers: (AuthUser & { password: string })[] = [
      { id: 1, name: 'Rahul Sharma', email: 'rahul@blog.com', password: 'password',
        avatar: 'R', role: 'admin' },
      { id: 2, name: 'Priya Patel', email: 'priya@blog.com', password: 'password',
        avatar: 'P', role: 'author' },
      { id: 3, name: 'Amit Kumar', email: 'amit@blog.com', password: 'password',
        avatar: 'A', role: 'reader' }
    ];

    const found = mockUsers.find(u => u.email === email && u.password === password);

    if (found) {
      const { password: _, ...user } = found;
      this.currentUser.set(user);
      this.token.set(`mock-jwt-token-${user.id}`);
      return true;
    }
    return false;
  }

  logout(): void {
    this.currentUser.set(null);
    this.token.set(null);
  }
}

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)); }
}

src/app/services/notifications.ts:

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

export interface Toast {
  id: number;
  message: string;
  type: 'success' | 'error' | 'info';
}

@Injectable({ providedIn: 'root' })
export class Notifications {
  private list = signal<Toast[]>([]);
  readonly toasts = this.list.asReadonly();
  private id = 1;

  show(message: string, type: Toast['type'] = 'info'): void {
    const toast: Toast = { id: this.id++, message, type };
    this.list.update(t => [...t, toast]);
    setTimeout(() => this.dismiss(toast.id), 4000);
  }

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

  dismiss(id: number) {
    this.list.update(t => t.filter(n => n.id !== id));
  }
}

src/app/services/blog.ts:

import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';

export interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
  createdAt: Date;
  category: string;
  readTime: number;
  authorName: string;
}

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

  private http = inject(HttpClient);

  getPosts(): Observable<Post[]> {
    return this.http.get<any[]>('https://jsonplaceholder.typicode.com/posts').pipe(
      map(posts => posts.slice(0, 12).map((p, i) => ({
        ...p,
        createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000),
        category: ['Angular', 'TypeScript', 'RxJS', 'JavaScript'][i % 4],
        readTime: Math.floor(Math.random() * 8) + 3,
        authorName: ['Rahul Sharma', 'Priya Patel', 'Amit Kumar'][i % 3]
      }))),
      catchError(() => of([]))
    );
  }

  getPostById(id: number): Observable<Post> {
    return this.http.get<any>(`https://jsonplaceholder.typicode.com/posts/${id}`).pipe(
      map(p => ({
        ...p,
        createdAt: new Date(),
        category: 'Angular',
        readTime: 6,
        authorName: 'Rahul Sharma'
      }))
    );
  }
}

Interceptors

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()));
};

src/app/interceptors/auth.ts:

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

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(Auth);
  const token = auth.getToken();

  if (token) {
    return next(req.clone({
      headers: req.headers.set('Authorization', `Bearer ${token}`)
    }));
  }

  return next(req);
};

Auth Guard

src/app/guards/auth.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../services/auth';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(Auth);
  const router = inject(Router);

  if (auth.isLoggedIn()) return true;

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

Pipes

src/app/pipes/time-ago.ts:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'timeAgo' })
export class TimeAgo implements PipeTransform {
  transform(value: Date | string): string {
    const date = new Date(value);
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
    if (seconds < 60) return 'just now';
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return `${hours}h ago`;
    const days = Math.floor(hours / 24);
    if (days < 30) return `${days}d ago`;
    return date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
  }
}

src/app/pipes/truncate.ts:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate' })
export class Truncate implements PipeTransform {
  transform(value: string, limit = 120): string {
    if (!value || value.length <= limit) return value;
    return value.substring(0, limit) + '...';
  }
}

Navbar Component

src/app/components/navbar/navbar.ts:

import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Auth } from '../../services/auth';

@Component({
  selector: 'app-navbar',
  imports: [RouterLink, RouterLinkActive],
  templateUrl: './navbar.html',
  styleUrl: './navbar.css'
})
export class Navbar {
  auth = inject(Auth);
}

src/app/components/navbar/navbar.html:

<nav>
  <a routerLink="/" class="brand">
    <span class="brand-icon">✦</span>
    DevBlog
  </a>

  <div class="nav-center">
    <a routerLink="/" routerLinkActive="active"
       [routerLinkActiveOptions]="{ exact: true }">Home</a>
    <a routerLink="/blog" routerLinkActive="active">Blog</a>
    @if (auth.isAuthor()) {
      <a routerLink="/write" routerLinkActive="active">Write</a>
    }
  </div>

  <div class="nav-right">
    @if (auth.isLoggedIn()) {
      <div class="user-pill">
        <div class="avatar">{{ auth.userInitial() }}</div>
        <span>{{ auth.userName() }}</span>
      </div>
      <button class="logout-btn" (click)="auth.logout()">Logout</button>
    } @else {
      <a routerLink="/login" class="login-btn">Sign In</a>
    }
  </div>
</nav>

src/app/components/navbar/navbar.css:

nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 40px;
  height: 64px;
  background: white;
  border-bottom: 1px solid #f0f0f0;
  position: sticky;
  top: 0;
  z-index: 100;
  box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}

.brand {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 20px;
  font-weight: 800;
  color: #1a1a2e;
  text-decoration: none;
  letter-spacing: -0.5px;
}

.brand-icon { color: #0070f3; font-size: 16px; }

.nav-center {
  display: flex;
  gap: 4px;
}

.nav-center a {
  color: #555;
  text-decoration: none;
  padding: 7px 14px;
  border-radius: 8px;
  font-size: 15px;
  transition: all 0.2s;
}

.nav-center a:hover { color: #0070f3; background: #f0f7ff; }
.nav-center a.active { color: #0070f3; font-weight: 600; background: #f0f7ff; }

.nav-right {
  display: flex;
  align-items: center;
  gap: 12px;
}

.user-pill {
  display: flex;
  align-items: center;
  gap: 8px;
  background: #f5f5f5;
  padding: 4px 12px 4px 4px;
  border-radius: 20px;
}

.avatar {
  width: 30px; height: 30px;
  background: #0070f3;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 700;
}

.user-pill span {
  font-size: 14px;
  color: #333;
  font-weight: 500;
}

.logout-btn {
  background: none;
  border: 1px solid #ddd;
  color: #666;
  padding: 6px 14px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
  transition: all 0.2s;
}

.logout-btn:hover { border-color: #e94560; color: #e94560; }

.login-btn {
  background: #0070f3;
  color: white;
  padding: 8px 18px;
  border-radius: 8px;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  transition: background 0.2s;
}

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

Toast Component

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

import { Component, inject } from '@angular/core';
import { NgClass } from '@angular/common';
import { trigger, transition, style, animate } from '@angular/animations';
import { Notifications } from '../../services/notifications';

@Component({
  selector: 'app-toast',
  imports: [NgClass],
  animations: [
    trigger('toastAnimation', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateX(100%)' }),
        animate('250ms ease-out', style({ opacity: 1, transform: 'translateX(0)' }))
      ]),
      transition(':leave', [
        animate('200ms ease-in', style({ opacity: 0, transform: 'translateX(100%)' }))
      ])
    ])
  ],
  template: `
    <div class="toast-container">
      @for (toast of notifications.toasts(); track toast.id) {
        <div class="toast" [ngClass]="'toast-' + toast.type" @toastAnimation>
          <span>{{ toast.message }}</span>
          <button (click)="notifications.dismiss(toast.id)">×</button>
        </div>
      }
    </div>
  `,
  styles: [`
    .toast-container {
      position: fixed; top: 80px; 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: 10px; font-size: 14px;
      min-width: 300px; font-weight: 500;
      box-shadow: 0 4px 20px rgba(0,0,0,0.12);
    }
    .toast-success { background: #ecfdf5; color: #065f46; border-left: 4px solid #10b981; }
    .toast-error { background: #fef2f2; color: #991b1b; border-left: 4px solid #ef4444; }
    .toast-info { background: #eff6ff; color: #1e40af; border-left: 4px solid #3b82f6; }
    button { background: none; border: none; font-size: 18px; cursor: pointer;
             color: inherit; margin-left: 12px; opacity: 0.6; }
    button:hover { opacity: 1; }
  `]
})
export class Toast {
  notifications = inject(Notifications);
}

Loader Component

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

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

@Component({
  selector: 'app-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 0%, #64ffda 50%, #0070f3 100%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      z-index: 9999;
    }
    @keyframes shimmer {
      0% { background-position: -200% 0; }
      100% { background-position: 200% 0; }
    }
  `]
})
export class Loader {
  loading = inject(Loading);
}

Home Page

src/app/pages/home/home.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Blog, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { Truncate } from '../../pipes/truncate';
import { trigger, transition, style, animate, query, stagger } from '@angular/animations';

@Component({
  selector: 'app-home',
  imports: [RouterLink, TimeAgo, Truncate],
  templateUrl: './home.html',
  styleUrl: './home.css',
  animations: [
    trigger('heroAnimation', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateY(30px)' }),
        animate('500ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
      ])
    ]),
    trigger('cardList', [
      transition('* => *', [
        query(':enter', [
          style({ opacity: 0, transform: 'translateY(20px)' }),
          stagger(80, [
            animate('400ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
          ])
        ], { optional: true })
      ])
    ])
  ]
})
export class Home implements OnInit {

  private blogService = inject(Blog);

  featuredPosts = signal<Post[]>([]);
  isLoading = signal(true);

  ngOnInit(): void {
    this.blogService.getPosts().subscribe({
      next: posts => {
        this.featuredPosts.set(posts.slice(0, 3));
        this.isLoading.set(false);
      },
      error: () => this.isLoading.set(false)
    });
  }
}

src/app/pages/home/home.html:

<div class="home">

  <section class="hero" @heroAnimation>
    <div class="hero-content">
      <span class="hero-tag">Welcome to DevBlog</span>
      <h1>Build. Learn.<br><span class="gradient-text">Grow.</span></h1>
      <p>Deep-dive tutorials on Angular, TypeScript, and modern web development. Written by developers, for developers.</p>
      <div class="hero-actions">
        <a routerLink="/blog" class="btn-primary">Browse Articles</a>
        <a routerLink="/login" class="btn-secondary">Start Writing</a>
      </div>
    </div>
    <div class="hero-visual">
      <div class="code-card">
        <div class="code-line"><span class="keyword">@Component</span><span class="bracket">(&#123;</span></div>
        <div class="code-line indent"><span class="prop">selector:</span> <span class="string">'app-root'</span><span class="comma">,</span></div>
        <div class="code-line indent"><span class="prop">imports:</span> <span class="bracket">[</span><span class="type">RouterOutlet</span><span class="bracket">]</span></div>
        <div class="code-line"><span class="bracket">&#125;)</span></div>
        <div class="code-line"><span class="keyword">export class</span> <span class="type">App</span> <span class="bracket">&#123;</span></div>
        <div class="code-line indent"><span class="prop">title</span> = <span class="fn">signal</span><span class="bracket">(</span><span class="string">'DevBlog'</span><span class="bracket">)</span><span class="comma">;</span></div>
        <div class="code-line"><span class="bracket">&#125;</span></div>
      </div>
    </div>
  </section>

  <section class="featured">
    <div class="section-header">
      <h2>Featured Articles</h2>
      <a routerLink="/blog" class="see-all">See all →</a>
    </div>

    @if (isLoading()) {
      <div class="posts-grid">
        @for (i of [1,2,3]; track i) {
          <div class="skeleton-card">
            <div class="sk-tag"></div>
            <div class="sk-title"></div>
            <div class="sk-text"></div>
            <div class="sk-text short"></div>
          </div>
        }
      </div>
    } @else {
      <div class="posts-grid" [@cardList]="featuredPosts().length">
        @for (post of featuredPosts(); track post.id) {
          <article class="post-card">
            <span class="tag">{{ post.category }}</span>
            <h3>{{ post.title | titlecase }}</h3>
            <p>{{ post.body | truncate:100 }}</p>
            <div class="card-footer">
              <span class="author">{{ post.authorName }}</span>
              <span class="meta">{{ post.readTime }}m · {{ post.createdAt | timeAgo }}</span>
            </div>
            <a [routerLink]="['/blog', post.id]" class="read-link">Read article →</a>
          </article>
        }
      </div>
    }
  </section>

  @defer (on viewport) {
    <section class="newsletter">
      <h2>Stay Updated</h2>
      <p>Get the latest articles delivered straight to your inbox.</p>
      <div class="newsletter-form">
        <input type="email" placeholder="your@email.com">
        <button>Subscribe</button>
      </div>
    </section>
  } @placeholder {
    <div style="height: 200px;"></div>
  }

</div>

src/app/pages/home/home.css:

.home { max-width: 1100px; margin: 0 auto; padding: 0 24px; }

.hero {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 60px;
  align-items: center;
  padding: 80px 0 64px;
}

.hero-tag {
  display: inline-block;
  background: #eff6ff;
  color: #0070f3;
  padding: 4px 12px;
  border-radius: 20px;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 16px;
}

.hero-content h1 {
  font-size: 56px;
  font-weight: 900;
  color: #1a1a2e;
  line-height: 1.1;
  letter-spacing: -2px;
  margin-bottom: 16px;
}

.gradient-text {
  background: linear-gradient(135deg, #0070f3, #64ffda);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.hero-content p {
  font-size: 17px;
  color: #666;
  line-height: 1.7;
  margin-bottom: 28px;
  max-width: 440px;
}

.hero-actions { display: flex; gap: 12px; }

.btn-primary {
  background: #0070f3;
  color: white;
  padding: 13px 26px;
  border-radius: 10px;
  text-decoration: none;
  font-weight: 600;
  font-size: 15px;
  transition: all 0.2s;
}

.btn-primary:hover {
  background: #005ac1;
  transform: translateY(-1px);
  box-shadow: 0 8px 20px rgba(0,112,243,0.3);
}

.btn-secondary {
  background: white;
  color: #0070f3;
  border: 2px solid #0070f3;
  padding: 12px 26px;
  border-radius: 10px;
  text-decoration: none;
  font-weight: 600;
  font-size: 15px;
  transition: all 0.2s;
}

.btn-secondary:hover { background: #f0f7ff; }

.code-card {
  background: #0a192f;
  border-radius: 16px;
  padding: 28px 32px;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.8;
  box-shadow: 0 20px 60px rgba(0,0,0,0.15);
}

.code-line { display: block; }
.indent { padding-left: 20px; }
.keyword { color: #ff79c6; }
.prop { color: #8be9fd; }
.string { color: #f1fa8c; }
.type { color: #50fa7b; }
.fn { color: #bd93f9; }
.bracket { color: #ccd6f6; }
.comma { color: #ccd6f6; }

.featured { padding: 0 0 80px; }

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

.section-header h2 {
  font-size: 28px;
  font-weight: 800;
  color: #1a1a2e;
}

.see-all {
  color: #0070f3;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
}

.posts-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
}

.post-card {
  background: white;
  border-radius: 16px;
  padding: 28px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.07);
  transition: all 0.3s;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.post-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 40px rgba(0,0,0,0.12);
}

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

.post-card h3 {
  font-size: 17px;
  font-weight: 700;
  color: #1a1a2e;
  line-height: 1.4;
}

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

.card-footer {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #aaa;
  padding-top: 8px;
  border-top: 1px solid #f0f0f0;
}

.author { color: #555; font-weight: 500; }

.read-link {
  color: #0070f3;
  text-decoration: none;
  font-size: 13px;
  font-weight: 600;
}

.skeleton-card {
  background: white;
  border-radius: 16px;
  padding: 28px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.sk-tag, .sk-title, .sk-text {
  background: #f0f0f0;
  border-radius: 6px;
  animation: pulse 1.5s infinite;
}

.sk-tag { height: 14px; width: 60px; }
.sk-title { height: 20px; width: 90%; }
.sk-text { height: 12px; width: 100%; }
.sk-text.short { width: 70%; }

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.newsletter {
  background: linear-gradient(135deg, #0070f3, #0050b3);
  color: white;
  padding: 56px 48px;
  border-radius: 20px;
  text-align: center;
  margin-bottom: 80px;
}

.newsletter h2 {
  font-size: 32px;
  font-weight: 800;
  margin-bottom: 10px;
}

.newsletter p {
  color: rgba(255,255,255,0.8);
  font-size: 16px;
  margin-bottom: 24px;
}

.newsletter-form { display: flex; gap: 12px; justify-content: center; max-width: 440px; margin: 0 auto; }

.newsletter-form input {
  flex: 1;
  padding: 13px 18px;
  border: none;
  border-radius: 10px;
  font-size: 15px;
}

.newsletter-form button {
  background: white;
  color: #0070f3;
  border: none;
  padding: 13px 24px;
  border-radius: 10px;
  font-weight: 700;
  cursor: pointer;
  font-size: 14px;
}

Blog Page

src/app/pages/blog/blog.ts:

import { Component, inject, OnInit, signal, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
import { Blog as BlogService, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { Truncate } from '../../pipes/truncate';
import { TitleCasePipe } from '@angular/common';
import { debounceTime, distinctUntilChanged, switchMap, of, startWith } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-blog',
  imports: [RouterLink, ReactiveFormsModule, TimeAgo, Truncate, TitleCasePipe],
  templateUrl: './blog.html',
  styleUrl: './blog.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlogPage implements OnInit {

  private blogService = inject(BlogService);

  allPosts = signal<Post[]>([]);
  filteredPosts = signal<Post[]>([]);
  selectedCategory = signal<string>('all');
  isLoading = signal(true);

  searchControl = new FormControl('');

  categories = computed(() => {
    const cats = [...new Set(this.allPosts().map(p => p.category))];
    return ['all', ...cats];
  });

  constructor() {
    // Search with debounce using takeUntilDestroyed
    this.searchControl.valueChanges.pipe(
      startWith(''),
      debounceTime(300),
      distinctUntilChanged(),
      takeUntilDestroyed()
    ).subscribe(term => {
      this.applyFilters(term || '');
    });
  }

  ngOnInit(): void {
    this.blogService.getPosts().subscribe({
      next: posts => {
        this.allPosts.set(posts);
        this.filteredPosts.set(posts);
        this.isLoading.set(false);
      },
      error: () => this.isLoading.set(false)
    });
  }

  setCategory(category: string): void {
    this.selectedCategory.set(category);
    this.applyFilters(this.searchControl.value || '');
  }

  applyFilters(searchTerm: string): void {
    let result = this.allPosts();

    if (this.selectedCategory() !== 'all') {
      result = result.filter(p => p.category === this.selectedCategory());
    }

    if (searchTerm.trim()) {
      result = result.filter(p =>
        p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
        p.body.toLowerCase().includes(searchTerm.toLowerCase())
      );
    }

    this.filteredPosts.set(result);
  }
}

src/app/pages/blog/blog.html:

<div class="blog-page">
  <div class="blog-header">
    <h1>All Articles</h1>
    <p>{{ allPosts().length }} articles across {{ categories().length - 1 }} categories</p>
  </div>

  <div class="blog-controls">
    <div class="search-wrapper">
      <span class="search-icon">🔍</span>
      <input type="text" [formControl]="searchControl" placeholder="Search articles...">
    </div>

    <div class="categories">
      @for (cat of categories(); track cat) {
        <button
          class="cat-btn"
          [class.active]="selectedCategory() === cat"
          (click)="setCategory(cat)">
          {{ cat | titlecase }}
        </button>
      }
    </div>
  </div>

  @if (isLoading()) {
    <div class="loading-grid">
      @for (i of [1,2,3,4,5,6]; track i) {
        <div class="skeleton-row">
          <div class="sk-tag"></div>
          <div class="sk-title"></div>
          <div class="sk-text"></div>
        </div>
      }
    </div>
  } @else {
    <div class="posts-count">
      Showing {{ filteredPosts().length }} article(s)
    </div>

    <div class="posts-list">
      @for (post of filteredPosts(); track post.id) {
        <article class="post-row">
          <div class="post-main">
            <span class="tag">{{ post.category }}</span>
            <h2>{{ post.title | titlecase }}</h2>
            <p>{{ post.body | truncate:140 }}</p>
            <div class="post-meta">
              <span class="author-chip">{{ post.authorName }}</span>
              <span>{{ post.readTime }} min read</span>
              <span>{{ post.createdAt | timeAgo }}</span>
            </div>
          </div>
          <a [routerLink]="['/blog', post.id]" class="read-btn">Read →</a>
        </article>
      } @empty {
        <div class="empty-state">
          <h3>No articles found</h3>
          <p>Try a different search term or category</p>
        </div>
      }
    </div>
  }
</div>

src/app/pages/blog/blog.css:

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

.blog-header { margin-bottom: 32px; }

.blog-header h1 {
  font-size: 40px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 6px;
}

.blog-header p { color: #888; font-size: 15px; }

.blog-controls { margin-bottom: 28px; }

.search-wrapper {
  position: relative;
  margin-bottom: 16px;
}

.search-icon {
  position: absolute;
  left: 14px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 16px;
}

.search-wrapper input {
  width: 100%;
  padding: 12px 14px 12px 42px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  font-size: 15px;
  box-sizing: border-box;
  transition: border-color 0.2s;
}

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

.categories { display: flex; gap: 8px; flex-wrap: wrap; }

.cat-btn {
  background: white;
  border: 1px solid #e0e0e0;
  color: #555;
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
}

.cat-btn:hover { border-color: #0070f3; color: #0070f3; }
.cat-btn.active { background: #0070f3; color: white; border-color: #0070f3; }

.posts-count { font-size: 13px; color: #888; margin-bottom: 16px; }

.posts-list { display: flex; flex-direction: column; gap: 2px; }

.post-row {
  background: white;
  padding: 24px 28px;
  border-radius: 12px;
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  box-shadow: 0 1px 4px rgba(0,0,0,0.06);
  transition: all 0.2s;
}

.post-row:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.10); transform: translateY(-1px); }

.post-main { flex: 1; }

.tag {
  font-size: 11px;
  font-weight: 700;
  color: #0070f3;
  text-transform: uppercase;
  letter-spacing: 1px;
  display: block;
  margin-bottom: 8px;
}

.post-main h2 {
  font-size: 18px;
  font-weight: 700;
  color: #1a1a2e;
  margin-bottom: 8px;
  line-height: 1.3;
}

.post-main p {
  font-size: 14px;
  color: #666;
  line-height: 1.6;
  margin-bottom: 12px;
}

.post-meta {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #aaa;
  align-items: center;
}

.author-chip {
  background: #f5f5f5;
  color: #555;
  padding: 2px 10px;
  border-radius: 20px;
  font-weight: 500;
}

.read-btn {
  background: #f0f7ff;
  color: #0070f3;
  padding: 10px 20px;
  border-radius: 8px;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  transition: all 0.2s;
  flex-shrink: 0;
}

.read-btn:hover { background: #0070f3; color: white; }

.empty-state { text-align: center; padding: 80px; color: #888; }
.empty-state h3 { font-size: 20px; margin-bottom: 8px; color: #555; }

.loading-grid { display: flex; flex-direction: column; gap: 12px; }

.skeleton-row {
  background: white;
  border-radius: 12px;
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.sk-tag, .sk-title, .sk-text {
  background: #f0f0f0;
  border-radius: 4px;
  animation: pulse 1.5s infinite;
}

.sk-tag { height: 12px; width: 60px; }
.sk-title { height: 18px; width: 70%; }
.sk-text { height: 12px; width: 90%; }

@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }

Post Detail Page

src/app/pages/post-detail/post-detail.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Blog, Post } from '../../services/blog';
import { TimeAgo } from '../../pipes/time-ago';
import { TitleCasePipe } from '@angular/common';
import { trigger, transition, style, animate } from '@angular/animations';

@Component({
  selector: 'app-post-detail',
  imports: [RouterLink, TimeAgo, TitleCasePipe],
  templateUrl: './post-detail.html',
  styleUrl: './post-detail.css',
  animations: [
    trigger('pageIn', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateY(16px)' }),
        animate('400ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
      ])
    ])
  ]
})
export class PostDetail implements OnInit {

  private route = inject(ActivatedRoute);
  private blogService = inject(Blog);

  post = signal<Post | null>(null);
  isLoading = signal(true);
  notFound = signal(false);

  ngOnInit(): void {
    const id = Number(this.route.snapshot.params['id']);
    this.blogService.getPostById(id).subscribe({
      next: post => {
        this.post.set(post);
        this.isLoading.set(false);
        if (post) document.title = post.title + ' | DevBlog';
      },
      error: () => {
        this.notFound.set(true);
        this.isLoading.set(false);
      }
    });
  }
}

src/app/pages/post-detail/post-detail.html:

<div class="post-detail">

  <div class="back-bar">
    <a routerLink="/blog" class="back-link">← Back to Blog</a>
  </div>

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

  @if (notFound()) {
    <div class="not-found">
      <h1>Post not found</h1>
      <a routerLink="/blog">← Back to Blog</a>
    </div>
  }

  @if (post()) {
    <article @pageIn>
      <header class="post-header">
        <span class="category">{{ post()!.category }}</span>
        <h1>{{ post()!.title | titlecase }}</h1>
        <div class="post-meta">
          <div class="author-info">
            <div class="author-avatar">{{ post()!.authorName[0] }}</div>
            <div>
              <strong>{{ post()!.authorName }}</strong>
              <span>{{ post()!.createdAt | timeAgo }} · {{ post()!.readTime }} min read</span>
            </div>
          </div>
        </div>
      </header>

      <div class="post-body">
        <p class="lead">{{ post()!.body }}</p>

        @defer (on idle) {
          <div class="related-section">
            <h3>Continue Reading</h3>
            <div class="related-links">
              <a routerLink="/blog" class="related-link">← More Articles</a>
            </div>
          </div>
        }
      </div>
    </article>
  }

</div>

src/app/pages/post-detail/post-detail.css:

.post-detail {
  max-width: 760px;
  margin: 0 auto;
  padding: 32px 24px 80px;
}

.back-bar { margin-bottom: 32px; }

.back-link {
  color: #0070f3;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
}

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

.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); } }

.not-found { text-align: center; padding: 80px 0; }
.not-found h1 { color: #555; margin-bottom: 16px; }
.not-found a { color: #0070f3; text-decoration: none; }

.post-header { margin-bottom: 40px; }

.category {
  display: inline-block;
  background: #eff6ff;
  color: #0070f3;
  padding: 4px 12px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 1px;
  margin-bottom: 16px;
}

.post-header h1 {
  font-size: 42px;
  font-weight: 900;
  color: #1a1a2e;
  line-height: 1.2;
  letter-spacing: -0.5px;
  margin-bottom: 24px;
}

.post-meta { display: flex; align-items: center; }

.author-info { display: flex; align-items: center; gap: 12px; }

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

.author-info strong { display: block; font-size: 15px; color: #1a1a2e; }
.author-info span { display: block; font-size: 13px; color: #888; margin-top: 2px; }

.post-body { border-top: 1px solid #f0f0f0; padding-top: 40px; }

.lead {
  font-size: 18px;
  line-height: 1.9;
  color: #444;
}

.related-section { margin-top: 56px; padding-top: 32px; border-top: 1px solid #f0f0f0; }
.related-section h3 { font-size: 18px; color: #1a1a2e; margin-bottom: 16px; }

.related-link {
  display: inline-block;
  background: #f0f7ff;
  color: #0070f3;
  padding: 10px 20px;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 600;
  font-size: 14px;
}

Login Page

src/app/pages/login/login.ts:

import { Component, inject, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Auth } from '../../services/auth';
import { Notifications } from '../../services/notifications';

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

  private fb = inject(FormBuilder);
  private auth = inject(Auth);
  private router = inject(Router);
  private route = inject(ActivatedRoute);
  private notifications = inject(Notifications);

  isLoading = signal(false);

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]]
  });

  get email() { return this.form.get('email')!; }
  get password() { return this.form.get('password')!; }

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

    this.isLoading.set(true);

    const { email, password } = this.form.value;
    const success = this.auth.login(email!, password!);

    if (success) {
      this.notifications.success(`Welcome back, ${this.auth.userName()}!`);
      const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
      this.router.navigateByUrl(returnUrl);
    } else {
      this.notifications.error('Invalid email or password.');
      this.isLoading.set(false);
    }
  }
}

src/app/pages/login/login.html:

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

    <div class="login-logo">✦</div>
    <h1>Welcome back</h1>
    <p class="subtitle">Sign in to your DevBlog account</p>

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

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

      <div class="field">
        <label>Password</label>
        <input type="password" formControlName="password"
               placeholder="Your password" (keyup.enter)="onLogin()">
        @if (password.invalid && password.touched) {
          <div class="error">Password must be at least 6 characters</div>
        }
      </div>

      <button type="submit" [disabled]="isLoading()">
        {{ isLoading() ? 'Signing in...' : 'Sign In' }}
      </button>

    </form>

    <div class="demo-accounts">
      <p>Demo accounts (all use password: <strong>password</strong>)</p>
      <div class="accounts">
        <span>rahul&#64;blog.com · Admin</span>
        <span>priya&#64;blog.com · Author</span>
        <span>amit&#64;blog.com · Reader</span>
      </div>
    </div>

  </div>
</div>

src/app/pages/login/login.css:

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

.login-card {
  background: white;
  padding: 48px 44px;
  border-radius: 20px;
  box-shadow: 0 8px 40px rgba(0,0,0,0.08);
  width: 100%;
  max-width: 420px;
  text-align: center;
}

.login-logo {
  font-size: 36px;
  color: #0070f3;
  margin-bottom: 16px;
}

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

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

.field { margin-bottom: 18px; text-align: left; }

label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #555;
  margin-bottom: 7px;
}

input {
  width: 100%;
  padding: 12px 14px;
  border: 1.5px solid #e0e0e0;
  border-radius: 10px;
  font-size: 15px;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

input:focus { outline: none; border-color: #0070f3; }
input.ng-invalid.ng-touched { border-color: #ef4444; }

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

button {
  width: 100%;
  padding: 14px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  margin-top: 8px;
  transition: all 0.2s;
}

button:hover { background: #005ac1; transform: translateY(-1px); }
button:disabled { background: #ccc; cursor: not-allowed; transform: none; }

.demo-accounts {
  margin-top: 28px;
  padding-top: 20px;
  border-top: 1px solid #f0f0f0;
}

.demo-accounts p { font-size: 13px; color: #888; margin-bottom: 10px; }

.accounts {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.accounts span {
  font-size: 12px;
  color: #666;
  background: #f5f5f5;
  padding: 4px 10px;
  border-radius: 6px;
}

Write Page (Protected)

src/app/pages/write/write.ts:

import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Notifications } from '../../services/notifications';
import { Auth } from '../../services/auth';

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

  private fb = inject(FormBuilder);
  private notifications = inject(Notifications);
  private router = inject(Router);
  auth = inject(Auth);

  isSubmitting = signal(false);

  form = this.fb.group({
    title: ['', [Validators.required, Validators.minLength(10)]],
    category: ['Angular', Validators.required],
    content: ['', [Validators.required, Validators.minLength(100)]]
  });

  get title() { return this.form.get('title')!; }
  get content() { return this.form.get('content')!; }

  get wordCount(): number {
    const text = this.form.get('content')?.value || '';
    return text.trim().split(/\s+/).filter(Boolean).length;
  }

  get estimatedReadTime(): number {
    return Math.max(1, Math.ceil(this.wordCount / 200));
  }

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

    this.isSubmitting.set(true);

    setTimeout(() => {
      this.notifications.success('Article published successfully!');
      this.router.navigate(['/blog']);
    }, 1500);
  }
}

src/app/pages/write/write.html:

<div class="write-page">
  <div class="write-header">
    <h1>Write an Article</h1>
    <div class="write-meta">
      <span>By {{ auth.userName() }}</span>
      <span>·</span>
      <span>{{ wordCount }} words · ~{{ estimatedReadTime }} min read</span>
    </div>
  </div>

  <form [formGroup]="form" class="write-form">

    <div class="field">
      <input
        type="text"
        formControlName="title"
        class="title-input"
        placeholder="Article title...">
      @if (title.invalid && title.touched) {
        <div class="error">Title must be at least 10 characters</div>
      }
    </div>

    <div class="field inline-field">
      <label>Category</label>
      <select formControlName="category">
        <option>Angular</option>
        <option>TypeScript</option>
        <option>RxJS</option>
        <option>JavaScript</option>
        <option>CSS</option>
      </select>
    </div>

    <div class="field">
      <textarea
        formControlName="content"
        class="content-input"
        rows="20"
        placeholder="Write your article here... (minimum 100 characters)">
      </textarea>
      @if (content.invalid && content.touched) {
        <div class="error">Content must be at least 100 characters</div>
      }
    </div>

    <div class="write-actions">
      <span class="char-count">{{ form.get('content')?.value?.length || 0 }} characters</span>
      <button
        type="button"
        class="publish-btn"
        (click)="onPublish()"
        [disabled]="form.invalid || isSubmitting()">
        {{ isSubmitting() ? 'Publishing...' : 'Publish Article' }}
      </button>
    </div>

  </form>
</div>

src/app/pages/write/write.css:

.write-page {
  max-width: 800px;
  margin: 0 auto;
  padding: 40px 24px 80px;
}

.write-header { margin-bottom: 32px; }

.write-header h1 {
  font-size: 32px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 6px;
}

.write-meta { font-size: 13px; color: #888; display: flex; gap: 8px; }

.write-form {
  background: white;
  border-radius: 16px;
  padding: 36px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.07);
}

.field { margin-bottom: 20px; }

.title-input {
  width: 100%;
  font-size: 28px;
  font-weight: 700;
  border: none;
  border-bottom: 2px solid #f0f0f0;
  padding: 8px 0;
  color: #1a1a2e;
  outline: none;
  box-sizing: border-box;
  transition: border-color 0.2s;
}

.title-input:focus { border-bottom-color: #0070f3; }

.inline-field {
  display: flex;
  align-items: center;
  gap: 12px;
}

.inline-field label {
  font-size: 13px;
  font-weight: 600;
  color: #555;
  white-space: nowrap;
}

.inline-field select {
  padding: 7px 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 14px;
  color: #333;
}

.content-input {
  width: 100%;
  border: 1.5px solid #e0e0e0;
  border-radius: 10px;
  padding: 16px;
  font-size: 16px;
  line-height: 1.8;
  color: #333;
  resize: vertical;
  font-family: inherit;
  box-sizing: border-box;
  transition: border-color 0.2s;
}

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

.error { margin-top: 6px; font-size: 12px; color: #ef4444; }

.write-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.char-count { font-size: 13px; color: #aaa; }

.publish-btn {
  background: #0070f3;
  color: white;
  border: none;
  padding: 13px 32px;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  transition: all 0.2s;
}

.publish-btn:hover {
  background: #005ac1;
  transform: translateY(-1px);
  box-shadow: 0 6px 20px rgba(0,112,243,0.3);
}

.publish-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }

Not Found Page

src/app/pages/not-found/not-found.ts:

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

@Component({
  selector: 'app-not-found',
  imports: [RouterLink],
  template: `
    <div class="not-found">
      <div class="code">404</div>
      <h1>Page not found</h1>
      <p>The page you are looking for does not exist.</p>
      <a routerLink="/">← Go Home</a>
    </div>
  `,
  styles: [`
    .not-found {
      text-align: center; padding: 120px 24px;
    }
    .code {
      font-size: 120px; font-weight: 900;
      color: #f0f0f0; line-height: 1; margin-bottom: 16px;
      letter-spacing: -4px;
    }
    h1 { font-size: 32px; font-weight: 800; color: #1a1a2e; margin-bottom: 10px; }
    p { color: #888; font-size: 16px; margin-bottom: 28px; }
    a {
      display: inline-block; background: #0070f3; color: white;
      padding: 12px 28px; border-radius: 10px; text-decoration: none;
      font-weight: 600; font-size: 15px;
    }
  `]
})
export class NotFound { }

Routes

src/app/app.routes.ts:

import { Routes } from '@angular/router';
import { authGuard } from './guards/auth';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./pages/home/home').then(m => m.Home)
  },
  {
    path: 'blog',
    loadComponent: () => import('./pages/blog/blog').then(m => m.BlogPage)
  },
  {
    path: 'blog/:id',
    loadComponent: () => import('./pages/post-detail/post-detail').then(m => m.PostDetail)
  },
  {
    path: 'login',
    loadComponent: () => import('./pages/login/login').then(m => m.Login)
  },
  {
    path: 'write',
    canActivate: [authGuard],
    loadComponent: () => import('./pages/write/write').then(m => m.Write)
  },
  {
    path: '**',
    loadComponent: () => import('./pages/not-found/not-found').then(m => m.NotFound)
  }
];

App Root

src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from './components/navbar/navbar';
import { Toast } from './components/toast/toast';
import { Loader } from './components/loader/loader';

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

src/styles.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}

body {
  background: #f9fafb;
  color: #1a1a2e;
  -webkit-font-smoothing: antialiased;
}

a { text-decoration: none; }

Run ng serve -o. Your complete DevBlog application is running with everything from all 10 phases working together.


Phase 10 — Complete Summary

Here is everything you learned in this phase.

Signals deep divesignal() creates reactive values. Read with (). Update with .set() and .update(). computed() for derived values — lazy, memoized, read-only. effect() for side effects that run when dependencies change — must be in constructor or injection context. Signal inputs with input() and input.required(). Signal outputs with output(). Two-way binding with model(). Signals and RxJS serve different purposes and are used together.

Pipes — transform display values without changing source data. Built-in: date, currency, number, percent, uppercase, lowercase, titlecase, slice, json, async. Custom pipes implement PipeTransform. Pure pipes only re-run when input reference changes. Impure pipes run every change detection cycle.

PerformanceChangeDetectionStrategy.OnPush skips unnecessary checks — requires new references not mutations. @defer with triggers (on viewport, on interaction, when condition) for deferred loading of heavy components. track by unique ID in @for for efficient DOM updates. NgOptimizedImage for optimized image loading. Lazy loaded routes with loadComponent.

AnimationsprovideAnimations() in app config. Five building blocks: trigger, state, transition, animate, style. :enter and :leave for element appearance and disappearance. stagger for animating lists.

Testing — Vitest as the test runner. describe for grouping, it for individual tests, beforeEach for setup. TestBed for Angular component and service testing. expect() with matchers like toBe, toEqual, toBeTruthy, toContain. Test services by injecting them. Test components with ComponentFixture and fixture.detectChanges(). Test pipes directly by instantiating them.

Production buildng build --configuration=production for optimized output. Bundle analysis with webpack-bundle-analyzer. Deploy to Netlify, Vercel, or Firebase with proper redirect rules for SPA routing.


You Have Completed the Angular Course

You have gone through all 10 phases from TypeScript fundamentals all the way to production deployment.

Phase 1 gave you TypeScript — the foundation that makes everything else possible.

Phase 2 gave you Angular fundamentals — how Angular starts, its project structure, standalone components.

Phase 3 took components deep — lifecycle hooks, @Input, @Output, @ViewChild, content projection, change detection.

Phase 4 covered data binding and directives — interpolation, property binding, event binding, two-way binding, @if, @for, @switch, custom directives.

Phase 5 built your understanding of services and dependency injection — the architecture that keeps Angular apps organized and scalable.

Phase 6 gave you routing — navigation, route parameters, guards, lazy loading, nested routes.

Phase 7 covered forms completely — template-driven, reactive, custom validators, FormArray, async validators.

Phase 8 connected your app to the real world — HttpClient, interceptors, error handling, environment configuration.

Phase 9 unlocked reactive programming — Observables, RxJS operators, Subjects, patterns like search with debounce.

Phase 10 brought it all together — Signals, pipes, performance optimization, animations, testing, and deployment.

You now have everything you need to build real, production-quality Angular applications. The next step is to keep building — every project you build will solidify these concepts further and reveal new things to learn.

No comments:

Post a Comment

Phase 4 - Fault Tolerance | Post 8 | Fault Tolerance — Keeping Your App Alive When Things Break

Post 8 of 15 | Phase 4: Fault Tolerance Fault Tolerance — Keeping Your App Alive When Things Break In every post so far we have assumed that...