Skip to main content

Angular Signals: Complete Guide to Reactive State Management

🔄 What is a Signal?

A signal is a variable + change notification - it's Angular's reactive primitive for state management.

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

// Think of a signal as a smart variable that notifies when it changes
const count = signal(0); // Initial value: 0

console.log(count()); // Read: 0
count.set(5); // Write: 5
console.log(count()); // Read: 5

// The magic: When count changes, Angular automatically knows to update the UI!

What Makes a Signal Special?

A signal is NOT just a regular variable:

// Regular variable - NO change notification
let regularCount = 0;
regularCount = 5; // Angular doesn't know this changed!

// Signal - WITH change notification
const signalCount = signal(0);
signalCount.set(5); // Angular automatically detects this change! ✨

Key Characteristics of Signals:

  • Always has a value - no undefined states
  • Synchronous - no async/await needed
  • Reactive - automatically notifies dependencies
  • Memoized - computed values are cached
  • Type-safe - full TypeScript support
  • Variable + Change Notification - the core concept!

🚀 Creating and Using Signals

Primitive Signals

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

@Component({
selector: 'app-counter',
template: `
<div>
<h2>Count: {{ count() }}</h2>
<h3>Name: {{ name() }}</h3>
<h3>Is Active: {{ isActive() }}</h3>

<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="toggleActive()">Toggle</button>
</div>
`
})
export class CounterComponent {
// Primitive signals - each is a "variable + change notification"
count = signal(0); // number signal
name = signal('John'); // string signal
isActive = signal(false); // boolean signal

increment() {
this.count.set(this.count() + 1);
}

decrement() {
this.count.update(value => value - 1); // Alternative syntax
}

toggleActive() {
this.isActive.update(active => !active);
}
}

Array Signals

@Component({
selector: 'app-todo-list',
template: `
<div>
<h3>Todos ({{ todos().length }})</h3>
<ul>
<li *ngFor="let todo of todos()">
{{ todo.text }} - {{ todo.completed ? 'Done' : 'Pending' }}
</li>
</ul>
<button (click)="addTodo()">Add Todo</button>
<button (click)="clearCompleted()">Clear Completed</button>
</div>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([
{ id: 1, text: 'Learn Angular Signals', completed: false },
{ id: 2, text: 'Build awesome apps', completed: false }
]);

addTodo() {
const newTodo = {
id: Date.now(),
text: `Todo ${this.todos().length + 1}`,
completed: false
};

// Using update() for arrays
this.todos.update(todos => [...todos, newTodo]);
}

clearCompleted() {
this.todos.update(todos => todos.filter(todo => !todo.completed));
}

toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
}

interface Todo {
id: number;
text: string;
completed: boolean;
}

Object Signals

@Component({
selector: 'app-user-profile',
template: `
<div>
<h2>{{ user().name }}</h2>
<p>Email: {{ user().email }}</p>
<p>Age: {{ user().age }}</p>
<p>Address: {{ user().address.city }}, {{ user().address.country }}</p>

<button (click)="updateAge()">Update Age</button>
<button (click)="updateAddress()">Update Address</button>
</div>
`
})
export class UserProfileComponent {
user = signal<User>({
name: 'John Doe',
email: 'john@example.com',
age: 30,
address: {
city: 'New York',
country: 'USA'
}
});

updateAge() {
this.user.update(user => ({
...user,
age: user.age + 1
}));
}

updateAddress() {
this.user.update(user => ({
...user,
address: {
...user.address,
city: 'San Francisco'
}
}));
}

// Using set() to replace entire object
resetUser() {
this.user.set({
name: 'Jane Smith',
email: 'jane@example.com',
age: 25,
address: {
city: 'Boston',
country: 'USA'
}
});
}
}

interface User {
name: string;
email: string;
age: number;
address: {
city: string;
country: string;
};
}

📊 Signal Methods: set(), update()

set() - Replace Entire Value

const count = signal(0);
const user = signal({ name: 'John', age: 30 });

// set() replaces the entire value
count.set(10); // count: 0 → 10
user.set({ name: 'Jane', age: 25 }); // Completely new object

// With arrays
const items = signal(['a', 'b', 'c']);
items.set(['x', 'y', 'z']); // Completely new array

update() - Transform Current Value

const count = signal(0);
const user = signal({ name: 'John', age: 30 });

// update() receives current value and returns new value
count.update(current => current + 1); // 0 → 1
count.update(current => current * 2); // 1 → 2

// With objects (immutable updates)
user.update(current => ({
...current,
age: current.age + 1
}));

// With arrays
const items = signal(['a', 'b']);
items.update(current => [...current, 'c']); // Add item
items.update(current => current.filter(item => item !== 'a')); // Remove item

When to Use Which?

// Use set() when:
// - Replacing entire value
// - Value comes from external source
// - Resetting to default state
this.user.set(apiResponse.user);
this.todos.set([]);
this.loading.set(false);

// Use update() when:
// - Modifying existing value
// - Calculations based on current value
// - Immutable updates to objects/arrays
this.count.update(n => n + 1);
this.user.update(u => ({ ...u, lastLogin: Date.now() }));
this.items.update(arr => [...arr, newItem]);

🧮 Computed Signals

Computed signals are read-only signals that derive their value from other signals.

@Component({
selector: 'app-shopping-cart',
template: `
<div>
<h2>Shopping Cart</h2>

<div *ngFor="let item of items()">
{{ item.name }} - ${{ item.price }} x {{ item.quantity }}
<button (click)="removeItem(item.id)">Remove</button>
</div>

<hr>
<h3>Total Items: {{ totalItems() }}</h3>
<h3>Total Price: ${{ totalPrice() }}</h3>
<h3>Tax: ${{ tax() }}</h3>
<h3>Final Total: ${{ finalTotal() }}</h3>

<button (click)="addItem()">Add Random Item</button>
</div>
`
})
export class ShoppingCartComponent {
items = signal<CartItem[]>([
{ id: 1, name: 'Laptop', price: 999, quantity: 1 },
{ id: 2, name: 'Mouse', price: 25, quantity: 2 }
]);

// Computed signals - automatically recalculate when dependencies change
totalItems = computed(() => {
console.log('Computing total items...');
return this.items().reduce((total, item) => total + item.quantity, 0);
});

totalPrice = computed(() => {
console.log('Computing total price...');
return this.items().reduce((total, item) => total + (item.price * item.quantity), 0);
});

tax = computed(() => {
console.log('Computing tax...');
return this.totalPrice() * 0.1; // 10% tax
});

finalTotal = computed(() => {
console.log('Computing final total...');
return this.totalPrice() + this.tax();
});

addItem() {
const newItem = {
id: Date.now(),
name: `Item ${this.items().length + 1}`,
price: Math.floor(Math.random() * 100) + 10,
quantity: 1
};
this.items.update(items => [...items, newItem]);
}

removeItem(id: number) {
this.items.update(items => items.filter(item => item.id !== id));
}
}

interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}

Computed Signal Memoization

@Component({})
export class MemoizationDemoComponent {
count = signal(0);

// Computed signal is memoized - only recalculates when count changes
expensiveComputation = computed(() => {
console.log('Expensive computation running...'); // Only logs when count changes
let result = 0;
for (let i = 0; i < this.count() * 1000000; i++) {
result += i;
}
return result;
});

constructor() {
// These won't trigger recomputation
console.log(this.expensiveComputation()); // Computes once
console.log(this.expensiveComputation()); // Uses cached value
console.log(this.expensiveComputation()); // Uses cached value

// This will trigger recomputation
this.count.set(5);
console.log(this.expensiveComputation()); // Computes again
}
}

⚡ Effects API

Effects run side effects when signals change. They're for logging, API calls, and DOM manipulation.

⚠️ IMPORTANT: Effects Should NOT Change Signal Values

// ❌ WRONG - Don't change signals in effects
effect(() => {
if (this.count() > 10) {
this.count.set(0); // ❌ DON'T DO THIS!
this.message.update(m => 'Reset'); // ❌ DON'T DO THIS!
}
});

// ✅ CORRECT - Use computed signals for derived state
const resetMessage = computed(() => {
return this.count() > 10 ? 'Should reset' : 'Normal';
});

// ✅ CORRECT - Use effects for side effects only
effect(() => {
if (this.count() > 10) {
console.log('Count exceeded 10!'); // ✅ Logging
this.analyticsService.track('overflow'); // ✅ API calls
this.showToast('Count too high!'); // ✅ UI notifications
}
});

Basic effect() Usage

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

@Component({
selector: 'app-effect-demo',
template: `
<div>
<h2>Count: {{ count() }}</h2>
<h3>Double: {{ double() }}</h3>

<button (click)="increment()">Increment</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class EffectDemoComponent {
count = signal(0);
double = computed(() => this.count() * 2);

constructor() {
// ✅ CORRECT - Effect for side effects only
effect(() => {
console.log(`Count changed to: ${this.count()}`);
console.log(`Double is now: ${this.double()}`);

// Side effects: logging, analytics, localStorage, etc.
localStorage.setItem('count', this.count().toString());
});

// ✅ CORRECT - Effect for API calls
effect(() => {
if (this.count() > 0) {
this.logToAnalytics(this.count());
}
});
}

increment() {
this.count.update(n => n + 1);
}

reset() {
this.count.set(0);
}

private logToAnalytics(value: number) {
// Simulate API call
console.log(`Analytics: Count is ${value}`);
}
}

Effect with onCleanup Callback

@Component({})
export class EffectCleanupComponent {
isActive = signal(false);

constructor() {
effect((onCleanup) => {
if (this.isActive()) {
console.log('Starting timer...');

const intervalId = setInterval(() => {
console.log('Timer tick');
}, 1000);

// ✅ onCleanup callback - runs when effect re-runs or component destroys
onCleanup(() => {
console.log('Cleaning up timer...');
clearInterval(intervalId);
});
}
});
}

toggle() {
this.isActive.update(active => !active);
}
}

Manual Effect Management with manualCleanup Flag

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

@Component({})
export class ManualEffectComponent implements OnDestroy {
count = signal(0);

// ✅ manualCleanup: true - Effect won't auto-cleanup on component destroy
private effectRef = effect(() => {
console.log('Count:', this.count());

// This effect will persist even after component destruction
// unless manually destroyed
}, {
manualCleanup: true // Don't auto-cleanup on component destroy
});

ngOnDestroy() {
// ✅ Manually destroy effect when needed
this.effectRef.destroy();
console.log('Effect manually destroyed');
}

increment() {
this.count.update(n => n + 1);
}
}

manualCleanup Flag Explained

@Component({})
export class CleanupComparisonComponent implements OnDestroy {
count = signal(0);

constructor() {
// ❌ Default behavior - auto cleanup on component destroy
effect(() => {
console.log('Auto cleanup effect:', this.count());
}); // Will automatically stop when component is destroyed

// ✅ Manual cleanup - persists beyond component lifecycle
const persistentEffect = effect(() => {
console.log('Manual cleanup effect:', this.count());
}, {
manualCleanup: true // This effect survives component destruction!
});

// Store reference for manual cleanup
this.persistentEffectRef = persistentEffect;
}

private persistentEffectRef: any;

ngOnDestroy() {
// Must manually destroy the persistent effect
this.persistentEffectRef.destroy();
}
}

🔄 afterNextRender and afterEveryRender: Browser-Specific DOM Hooks

These functions run code after Angular finishes rendering and are essential for DOM manipulation and third-party library integration. They are browser-specific hooks designed for client-side operations.

⚠️ Important Notes:

  • 🌐 Browser-specific hooks - Only available in browser environments
  • ❌ Not available for SSR - Don't work on Server-Side Rendering or pre-rendering
  • 🎯 Third-party library integration - Designed for libraries that need DOM to be ready
  • 📍 Injection context required - Must be called in constructor or inject()
  • 🔄 Angular 20 update: afterRender renamed to afterEveryRender (stable)

Why Browser-Specific Hooks Are Needed

Many third-party libraries require the DOM to be fully rendered before initialization:

// Examples of libraries that need DOM-ready state:
// - Chart.js (canvas manipulation)
// - Google Maps (DOM container required)
// - Monaco Editor (DOM element needed)
// - D3.js (DOM selections and manipulations)
// - jQuery plugins (DOM querying)
// - Web Components (custom element registration)

afterNextRender - One-Time Browser Operations

afterNextRender registers a callback that executes ONLY ONCE after the next render cycle, when the DOM is fully loaded. Perfect for third-party library initialization.

Perfect for:

  • Third-party library initialization (Chart.js, Google Maps, Monaco Editor)
  • One-time DOM manipulations
  • Setting up element observers (ResizeObserver, IntersectionObserver)
  • Initial measurements and setup
  • Focus and scroll positioning
import { Component, afterNextRender, signal, ElementRef, viewChild } from '@angular/core';

@Component({
selector: 'app-third-party-libraries',
template: `
<div>
<h2>Third-Party Library Integration (Browser Only)</h2>

<!-- Chart.js Integration -->
<div #chartContainer style="width: 600px; height: 400px; border: 1px solid #ccc;">
<canvas #chartCanvas></canvas>
</div>

<!-- Google Maps Integration -->
<div #mapContainer style="width: 100%; height: 300px; border: 1px solid #999;">
</div>

<!-- Monaco Editor Integration -->
<div #editorContainer style="width: 100%; height: 200px; border: 1px solid #777;">
</div>

<button (click)="updateChartData()">Update Chart Data</button>
</div>
`
})
export class ThirdPartyLibrariesComponent {
chartData = signal([10, 20, 30, 40, 50]);

chartContainer = viewChild<ElementRef>('chartContainer');
chartCanvas = viewChild<ElementRef>('chartCanvas');
mapContainer = viewChild<ElementRef>('mapContainer');
editorContainer = viewChild<ElementRef>('editorContainer');

private chartInstance: any = null;
private mapInstance: any = null;
private editorInstance: any = null;

constructor() {
// ✅ Browser-specific: Initialize Chart.js once DOM is ready
afterNextRender(() => {
console.log('🎯 BROWSER: Initializing Chart.js...');
this.initializeChart();
});

// ✅ Browser-specific: Initialize Google Maps once DOM is ready
afterNextRender(() => {
console.log('🗺️ BROWSER: Initializing Google Maps...');
this.initializeGoogleMaps();
});

// ✅ Browser-specific: Initialize Monaco Editor once DOM is ready
afterNextRender(() => {
console.log('💻 BROWSER: Initializing Monaco Editor...');
this.initializeMonacoEditor();
});

// ✅ Browser-specific: Set up DOM observers
afterNextRender(() => {
console.log('👁️ BROWSER: Setting up DOM observers...');
this.setupDOMObservers();
});
}

private initializeChart() {
const canvas = this.chartCanvas()?.nativeElement;
if (canvas && typeof window !== 'undefined') {
// Check if we're in browser environment
console.log('Chart canvas ready:', canvas.width, 'x', canvas.height);

// Real Chart.js initialization would be:
// import Chart from 'chart.js/auto';
// this.chartInstance = new Chart(canvas, { ... });

// Simulation for demo:
this.chartInstance = { type: 'chart', initialized: true };
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#3498db';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.font = '20px Arial';
ctx.textAlign = 'center';
ctx.fillText('📊 Chart.js Ready!', canvas.width/2, canvas.height/2);
}
}

private initializeGoogleMaps() {
const container = this.mapContainer()?.nativeElement;
if (container && typeof window !== 'undefined') {
console.log('Map container ready:', container.offsetWidth, 'x', container.offsetHeight);

// Real Google Maps initialization would be:
// this.mapInstance = new google.maps.Map(container, {
// center: { lat: -34.397, lng: 150.644 },
// zoom: 8
// });

// Simulation for demo:
this.mapInstance = { type: 'map', initialized: true };
container.innerHTML = '<p style="text-align: center; line-height: 300px; background: #e8f5e8; margin: 0;">🗺️ Google Maps Ready!</p>';
}
}

private initializeMonacoEditor() {
const container = this.editorContainer()?.nativeElement;
if (container && typeof window !== 'undefined') {
console.log('Editor container ready:', container.offsetWidth, 'x', container.offsetHeight);

// Real Monaco Editor initialization would be:
// import * as monaco from 'monaco-editor';
// this.editorInstance = monaco.editor.create(container, {
// value: 'console.log("Hello World!");',
// language: 'javascript'
// });

// Simulation for demo:
this.editorInstance = { type: 'editor', initialized: true };
container.innerHTML = '<p style="text-align: center; line-height: 200px; background: #f0f0f0; margin: 0; font-family: monospace;">💻 Monaco Editor Ready!</p>';
}
}

private setupDOMObservers() {
if (typeof window !== 'undefined') {
// Set up ResizeObserver for chart container
const chartContainer = this.chartContainer()?.nativeElement;
if (chartContainer) {
const resizeObserver = new ResizeObserver((entries) => {
console.log('📏 Chart container resized:', entries[0].contentRect);
// Resize chart if needed
});
resizeObserver.observe(chartContainer);
}

// Set up IntersectionObserver for performance
const mapContainer = this.mapContainer()?.nativeElement;
if (mapContainer) {
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log('👁️ Map visibility changed:', entry.isIntersecting);
// Pause/resume map updates based on visibility
});
});
intersectionObserver.observe(mapContainer);
}
}
}

updateChartData() {
this.chartData.update(data => data.map(val => Math.floor(Math.random() * 100)));

// Update chart with new data (if chart is initialized)
if (this.chartInstance) {
console.log('📊 Updating chart with new data:', this.chartData());
// In real Chart.js: this.chartInstance.data.datasets[0].data = this.chartData();
// this.chartInstance.update();
}
}
}

afterEveryRender - Continuous Browser Monitoring

afterEveryRender (formerly afterRender) executes a callback after every render cycle. Perfect for dynamic DOM adjustments and performance monitoring.

Perfect for:

  • Dynamic size adjustments based on content changes
  • Performance monitoring in browser environment
  • Animation updates that depend on DOM state
  • Content overflow detection
  • ⚠️ Use judiciously - can impact performance
import { Component, afterEveryRender, signal, ElementRef, viewChild } from '@angular/core';

@Component({
selector: 'app-dynamic-browser-content',
template: `
<div>
<h2>Dynamic Browser Content Monitoring</h2>
<h3>Render Count: {{ renderCount() }}</h3>
<h3>Content Height: {{ contentHeight() }}px</h3>
<h3>Viewport Width: {{ viewportWidth() }}px</h3>
<h3>Items: {{ items().length }}</h3>

<div #dynamicContent style="border: 1px solid #ccc; padding: 10px; max-height: 300px; overflow: auto; transition: all 0.3s;">
<p *ngFor="let item of items(); let i = index" style="margin: 5px 0;">
{{ item }} - Dynamically measured content
</p>
</div>

<button (click)="addItem()">Add Item</button>
<button (click)="removeItem()">Remove Item</button>
<button (click)="forceRender()">Force Render</button>
</div>
`
})
export class DynamicBrowserContentComponent {
items = signal(['Browser Item 1', 'Browser Item 2']);
renderCount = signal(0);
contentHeight = signal(0);
viewportWidth = signal(0);

dynamicContent = viewChild<ElementRef>('dynamicContent');

constructor() {
// ✅ Browser-specific: Monitor content changes after every render
afterEveryRender(() => {
if (typeof window !== 'undefined') {
this.renderCount.update(n => n + 1);
console.log('🔄 BROWSER: Render #', this.renderCount());

// Browser-specific DOM measurements
this.measureContentDimensions();
this.updateViewportInfo();
this.adjustDynamicLayout();
this.monitorPerformance();
}
});

// ✅ Browser-specific: Animation and visual updates
afterEveryRender(() => {
if (typeof window !== 'undefined') {
this.updateAnimationsAndEffects();
}
});
}

private measureContentDimensions() {
const contentEl = this.dynamicContent()?.nativeElement;
if (contentEl) {
const newHeight = contentEl.scrollHeight;
this.contentHeight.set(newHeight);
console.log('📏 BROWSER: Content height measured:', newHeight);

// Check for content overflow
const isOverflowing = contentEl.scrollHeight > contentEl.clientHeight;
console.log('📦 BROWSER: Content overflowing:', isOverflowing);
}
}

private updateViewportInfo() {
if (typeof window !== 'undefined') {
const newWidth = window.innerWidth;
this.viewportWidth.set(newWidth);

// Responsive adjustments based on viewport
const contentEl = this.dynamicContent()?.nativeElement;
if (contentEl) {
if (newWidth < 768) {
contentEl.style.maxHeight = '200px'; // Mobile
} else if (newWidth < 1024) {
contentEl.style.maxHeight = '250px'; // Tablet
} else {
contentEl.style.maxHeight = '300px'; // Desktop
}
}
}
}

private adjustDynamicLayout() {
const contentEl = this.dynamicContent()?.nativeElement;
if (contentEl) {
const itemCount = this.items().length;

// Dynamic styling based on content
if (itemCount > 8) {
contentEl.style.backgroundColor = '#ffe6e6'; // Red tint for many items
contentEl.style.border = '2px solid #ff9999';
} else if (itemCount > 4) {
contentEl.style.backgroundColor = '#fff2e6'; // Orange tint for medium items
contentEl.style.border = '2px solid #ffcc99';
} else {
contentEl.style.backgroundColor = '#e6f3ff'; // Blue tint for few items
contentEl.style.border = '1px solid #99ccff';
}

// Dynamic padding based on content
contentEl.style.padding = `${Math.min(itemCount * 2, 20)}px`;
}
}

private monitorPerformance() {
if (typeof window !== 'undefined') {
const renderTime = performance.now();
console.log('⚡ BROWSER: Render completed at:', Math.round(renderTime));

// Performance monitoring
if (renderTime > 16.67) { // More than 60fps threshold
console.warn('⚠️ BROWSER: Slow render detected:', renderTime + 'ms');
}
}
}

private updateAnimationsAndEffects() {
const contentEl = this.dynamicContent()?.nativeElement;
if (contentEl && typeof window !== 'undefined') {
// Add visual effects based on current state
const itemCount = this.items().length;

// Subtle animation effects
contentEl.style.transform = `scale(${Math.min(1 + itemCount * 0.01, 1.1)})`;

// Update CSS custom properties for animations
document.documentElement.style.setProperty('--item-count', itemCount.toString());

console.log('🎭 BROWSER: Animations updated for', itemCount, 'items');
}
}

addItem() {
const newItem = `Dynamic Browser Item ${this.items().length + 1}`;
this.items.update(items => [...items, newItem]);
// This will trigger afterEveryRender callbacks
}

removeItem() {
this.items.update(items => items.slice(0, -1));
// This will trigger afterEveryRender callbacks
}

forceRender() {
// This will trigger afterEveryRender even without signal changes
console.log('🔄 BROWSER: Forcing render...');
}
}

Browser Environment Detection

@Component({
selector: 'app-browser-detection',
template: `
<div>
<h2>Browser Environment Detection</h2>
<p>Is Browser: {{ isBrowser() }}</p>
<p>Platform: {{ platform() }}</p>
<div #browserOnlyContent>
<p *ngIf="isBrowser()">This content is only rendered in browser!</p>
<p *ngIf="!isBrowser()">This is server-side rendering.</p>
</div>
</div>
`
})
export class BrowserDetectionComponent {
isBrowser = signal(false);
platform = signal('unknown');

browserOnlyContent = viewChild<ElementRef>('browserOnlyContent');

constructor() {
// Set initial browser detection
this.detectEnvironment();

// ✅ Only run in browser environment
if (typeof window !== 'undefined') {
afterNextRender(() => {
console.log('🌐 BROWSER: Browser-specific initialization');
this.initializeBrowserFeatures();
});

afterEveryRender(() => {
console.log('🌐 BROWSER: Browser-specific monitoring');
this.monitorBrowserState();
});
} else {
console.log('🖥️ SERVER: Skipping browser-specific hooks during SSR');
}
}

private detectEnvironment() {
if (typeof window !== 'undefined') {
this.isBrowser.set(true);
this.platform.set(navigator.platform || 'browser');
} else {
this.isBrowser.set(false);
this.platform.set('server');
}
}

private initializeBrowserFeatures() {
// Features that only work in browser
console.log('🌐 Window size:', window.innerWidth, 'x', window.innerHeight);
console.log('🌐 User agent:', navigator.userAgent);
console.log('🌐 Language:', navigator.language);

// Set up browser-specific event listeners
window.addEventListener('resize', () => {
console.log('🌐 Window resized');
});
}

private monitorBrowserState() {
if (typeof document !== 'undefined') {
const contentEl = this.browserOnlyContent()?.nativeElement;
if (contentEl) {
console.log('🌐 Browser content measured:', {
width: contentEl.offsetWidth,
height: contentEl.offsetHeight,
visible: contentEl.offsetParent !== null
});
}
}
}
}

SSR-Safe Implementation Pattern

@Component({
selector: 'app-ssr-safe',
template: `
<div>
<h2>SSR-Safe Third-Party Integration</h2>
<div #chartContainer [style.display]="chartReady() ? 'block' : 'none'">
Chart will appear here when browser is ready
</div>
<div *ngIf="!chartReady()" style="padding: 50px; text-align: center; background: #f0f0f0;">
📊 Chart loading... (Browser only)
</div>
<p>Server-safe content: {{ serverSafeData() }}</p>
</div>
`
})
export class SSRSafeComponent {
chartReady = signal(false);
serverSafeData = signal('This works on both server and browser');

chartContainer = viewChild<ElementRef>('chartContainer');

constructor() {
// ✅ This runs on both server and browser
console.log('🔄 Universal: Component constructor');

// ✅ Browser-specific initialization (safe from SSR)
if (typeof window !== 'undefined') {
afterNextRender(() => {
console.log('🌐 BROWSER-ONLY: Initializing chart');
this.initializeChart();
});
} else {
console.log('🖥️ SERVER: Skipping browser-specific initialization');
}
}

private initializeChart() {
// This only runs in browser
const container = this.chartContainer()?.nativeElement;
if (container) {
// Simulate chart initialization
setTimeout(() => {
container.innerHTML = '<p style="text-align: center; padding: 50px; background: #e8f5e8;">📊 Chart initialized in browser!</p>';
this.chartReady.set(true);
}, 1000);
}
}
}

Render Phase Control with Browser Checks

import { Component, afterNextRender, afterEveryRender, AfterRenderPhase } from '@angular/core';

@Component({
selector: 'app-browser-phases',
template: `
<div>
<h2>Browser-Specific Render Phases</h2>
<h3>Count: {{ count() }}</h3>
<button (click)="increment()">Increment</button>
</div>
`
})
export class BrowserPhasesComponent {
count = signal(0);

constructor() {
// Only set up render phase hooks in browser
if (typeof window !== 'undefined') {

// ✅ EarlyRead: Browser-specific DOM reading
afterEveryRender(() => {
console.log('📖 BROWSER EarlyRead: Reading DOM properties');
const element = document.querySelector('h2');
if (element) {
console.log('H2 computed style:', window.getComputedStyle(element).fontSize);
}
}, { phase: AfterRenderPhase.EarlyRead });

// ✅ Write: Browser-specific DOM modifications
afterEveryRender(() => {
console.log('✏️ BROWSER Write: Modifying DOM');
const element = document.querySelector('h3');
if (element && this.count() > 5) {
element.style.color = 'red';
element.style.transform = 'scale(1.1)';
} else if (element) {
element.style.color = 'black';
element.style.transform = 'scale(1)';
}
}, { phase: AfterRenderPhase.Write });

// ✅ MixedReadWrite: Complex browser operations
afterEveryRender(() => {
console.log('🔄 BROWSER MixedReadWrite: Complex DOM operations');
const button = document.querySelector('button');
if (button) {
const rect = button.getBoundingClientRect(); // Read
button.style.minWidth = `${Math.max(100, rect.width + 10)}px`; // Write
}
}, { phase: AfterRenderPhase.MixedReadWrite });

// ✅ Read: Final browser measurements
afterEveryRender(() => {
console.log('📚 BROWSER Read: Final measurements');
const container = document.querySelector('div');
if (container) {
console.log('Container final size:', {
width: container.offsetWidth,
height: container.offsetHeight,
scrollHeight: container.scrollHeight
});
}
}, { phase: AfterRenderPhase.Read });

} else {
console.log('🖥️ SERVER: Skipping browser-specific render phase hooks');
}
}

increment() {
this.count.update(n => n + 1);
}
}

🔒 Read-Only Signals

Create read-only versions of signals to prevent external modification.

@Injectable({
providedIn: 'root'
})
export class CounterService {
// Private writable signal
private _count = signal(0);
private _loading = signal(false);

// Public read-only signals
readonly count = this._count.asReadonly();
readonly loading = this._loading.asReadonly();

// Computed read-only signal
readonly doubleCount = computed(() => this._count() * 2);

// Methods to modify private signals
increment() {
this._count.update(n => n + 1);
}

decrement() {
this._count.update(n => n - 1);
}

reset() {
this._count.set(0);
}

async loadData() {
this._loading.set(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
this.increment();
} finally {
this._loading.set(false);
}
}
}

@Component({
selector: 'app-readonly-demo',
template: `
<div>
<h2>Count: {{ counterService.count() }}</h2>
<h3>Double: {{ counterService.doubleCount() }}</h3>
<h3>Loading: {{ counterService.loading() }}</h3>

<button (click)="counterService.increment()">+</button>
<button (click)="counterService.decrement()">-</button>
<button (click)="counterService.reset()">Reset</button>
<button (click)="counterService.loadData()" [disabled]="counterService.loading()">
Load Data
</button>
</div>
`
})
export class ReadonlyDemoComponent {
constructor(public counterService: CounterService) {
// These would cause TypeScript errors:
// this.counterService.count.set(100); // ❌ Error: read-only
// this.counterService.doubleCount.set(200); // ❌ Error: computed is read-only
}
}

🏗️ Advanced Patterns

Signal-based Form Management

interface FormState {
username: string;
email: string;
password: string;
}

interface FormErrors {
username?: string;
email?: string;
password?: string;
}

@Component({
selector: 'app-signal-form',
template: `
<form>
<div>
<label>Username:</label>
<input
[value]="formData().username"
(input)="updateField('username', $event)"
/>
<span *ngIf="errors().username" class="error">
{{ errors().username }}
</span>
</div>

<div>
<label>Email:</label>
<input
type="email"
[value]="formData().email"
(input)="updateField('email', $event)"
/>
<span *ngIf="errors().email" class="error">
{{ errors().email }}
</span>
</div>

<div>
<label>Password:</label>
<input
type="password"
[value]="formData().password"
(input)="updateField('password', $event)"
/>
<span *ngIf="errors().password" class="error">
{{ errors().password }}
</span>
</div>

<button
type="submit"
[disabled]="!isValid()"
(click)="onSubmit()"
>
Submit ({{ isValid() ? 'Valid' : 'Invalid' }})
</button>
</form>
`
})
export class SignalFormComponent {
formData = signal<FormState>({
username: '',
email: '',
password: ''
});

errors = computed(() => {
const data = this.formData();
const errors: FormErrors = {};

if (data.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}

if (!data.email.includes('@')) {
errors.email = 'Email must be valid';
}

if (data.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}

return errors;
});

isValid = computed(() => {
return Object.keys(this.errors()).length === 0;
});

constructor() {
// ✅ CORRECT - Effect for side effects (auto-save)
effect(() => {
const data = this.formData();
localStorage.setItem('formData', JSON.stringify(data));
console.log('Form auto-saved:', data);
});
}

updateField(field: keyof FormState, event: Event) {
const value = (event.target as HTMLInputElement).value;
this.formData.update(data => ({
...data,
[field]: value
}));
}

onSubmit() {
if (this.isValid()) {
console.log('Submitting:', this.formData());
}
}
}

State Management with Signals

// State management service
@Injectable({
providedIn: 'root'
})
export class AppStateService {
// Private state
private _user = signal<User | null>(null);
private _theme = signal<'light' | 'dark'>('light');
private _notifications = signal<Notification[]>([]);
private _loading = signal(false);

// Public read-only state
readonly user = this._user.asReadonly();
readonly theme = this._theme.asReadonly();
readonly notifications = this._notifications.asReadonly();
readonly loading = this._loading.asReadonly();

// Computed state
readonly isLoggedIn = computed(() => this._user() !== null);
readonly unreadNotifications = computed(() =>
this._notifications().filter(n => !n.read).length
);

constructor() {
// ✅ CORRECT - Effects for side effects
effect(() => {
const theme = this._theme();
document.body.className = theme;
localStorage.setItem('theme', theme);
});

effect(() => {
const user = this._user();
if (user) {
console.log('User logged in:', user.name);
this.loadUserNotifications();
} else {
console.log('User logged out');
this._notifications.set([]);
}
});
}

// Actions
login(user: User) {
this._loading.set(true);
// Simulate async login
setTimeout(() => {
this._user.set(user);
this._loading.set(false);
}, 1000);
}

logout() {
this._user.set(null);
}

toggleTheme() {
this._theme.update(theme => theme === 'light' ? 'dark' : 'light');
}

addNotification(notification: Omit<Notification, 'id'>) {
const newNotification = {
...notification,
id: Date.now()
};
this._notifications.update(notifications =>
[newNotification, ...notifications]
);
}

markNotificationRead(id: number) {
this._notifications.update(notifications =>
notifications.map(n =>
n.id === id ? { ...n, read: true } : n
)
);
}

private loadUserNotifications() {
// Simulate loading notifications
const notifications = [
{ id: 1, message: 'Welcome!', read: false },
{ id: 2, message: 'New feature available', read: false }
];
this._notifications.set(notifications);
}
}

interface User {
id: number;
name: string;
email: string;
}

interface Notification {
id: number;
message: string;
read: boolean;
}

🔑 Key Takeaways

Signal Fundamentals:

  • Signals are reactive primitives - variable + change notification
  • Always have a value - no undefined states
  • Synchronous - no async complexity
  • Type-safe - full TypeScript support

Signal Types:

  • Writable signals: signal() - can use set() and update()
  • Computed signals: computed() - read-only, memoized, derived
  • Read-only signals: signal().asReadonly() - prevent external modification

Effects Rules:

  • Use for side effects: logging, API calls, DOM manipulation, localStorage
  • DON'T change signals: No set(), update(), or mutate() in effects
  • Use computed: For derived state based on other signals
  • Use onCleanup: For cleanup when effects re-run
  • Use manualCleanup: When effects need to outlive component lifecycle

Browser-Specific Render Hooks:

  • 🌐 Browser-only: afterNextRender and afterEveryRender are browser-specific hooks
  • ❌ No SSR support: Don't work on Server-Side Rendering or pre-rendering
  • 🎯 Third-party integration: Essential for libraries that need DOM to be ready
  • afterNextRender: Runs once after next render - perfect for third-party library initialization
  • afterEveryRender: Runs every render - perfect for dynamic content monitoring (use judiciously)
  • Environment detection: Always check typeof window !== 'undefined' for safety
  • Render phases: EarlyRead, Write, MixedReadWrite, Read for fine-grained control

Choosing the Right Hook:

// ✅ Use afterNextRender for one-time browser operations
if (typeof window !== 'undefined') {
afterNextRender(() => {
// - Third-party library initialization (Chart.js, Google Maps, Monaco)
// - Element observers setup (ResizeObserver, IntersectionObserver)
// - Initial DOM measurements
// - Focus/scroll positioning
});
}

// ✅ Use afterEveryRender for continuous browser monitoring (be careful!)
if (typeof window !== 'undefined') {
afterEveryRender(() => {
// - Dynamic size adjustments
// - Performance monitoring
// - Animation updates
// - Content overflow detection
});
}

Best Practices:

// ✅ Use computed for derived state
const total = computed(() => items().reduce((sum, item) => sum + item.price, 0));

// ✅ Use effects for side effects only
effect(() => localStorage.setItem('data', JSON.stringify(data())));

// ✅ Use read-only signals for public APIs
readonly count = this._count.asReadonly();

// ✅ Use afterNextRender for browser-specific initialization
if (typeof window !== 'undefined') {
afterNextRender(() => this.initializeThirdPartyLibrary());
}

// ✅ Use afterEveryRender sparingly for browser monitoring
if (typeof window !== 'undefined') {
afterEveryRender(() => this.adjustDynamicLayout());
}

// ✅ Use manualCleanup when needed
const persistentEffect = effect(() => {}, { manualCleanup: true });

// ✅ Always check browser environment for safety
if (typeof window !== 'undefined') {
// Browser-specific code here
} else {
console.log('SSR: Skipping browser-specific hooks');
}

// ❌ Don't change signals in effects
effect(() => {
count.set(otherSignal() * 2); // Use computed instead!
});

Angular Signals provide a powerful, reactive, and performant way to manage state with automatic change detection and memoization! The browser-specific render hooks make DOM integration seamless for modern web applications while maintaining SSR compatibility! 🚀