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]);
� Immutability with Signals
Signals rely on reference equality (by default ===) to detect changes. This means you must never mutate the current value in place — always produce a new object or array. Mutating in place changes the data but keeps the same reference, so Angular sees no change and the UI won't update.
Why Mutation Breaks Reactivity
const user = signal({ name: 'John', age: 30 });
// ❌ WRONG - mutating in place
user().age = 31; // Same reference → signal doesn't notify
console.log(user().age); // 31, but the UI is NOT updated!
// ✅ CORRECT - create a new object
user.update(u => ({ ...u, age: 31 })); // New reference → UI updates ✨
The same rule applies to arrays:
const items = signal(['a', 'b']);
// ❌ WRONG - mutates the same array reference
items().push('c'); // No change detected
// ✅ CORRECT - return a brand new array
items.update(list => [...list, 'c']); // Add
items.update(list => list.filter(x => x !== 'a')); // Remove
items.update(list => list.map(x => x.toUpperCase())); // Transform
Immutable Update Cheat Sheet
// Objects
signal.update(obj => ({ ...obj, key: newValue })); // Update a field
signal.update(obj => { const { removed, ...rest } = obj; return rest; }); // Remove a field
// Nested objects - spread at every level you change
signal.update(state => ({
...state,
address: { ...state.address, city: 'San Francisco' }
}));
// Arrays
signal.update(arr => [...arr, item]); // Add
signal.update(arr => arr.filter(x => x.id !== id)); // Remove
signal.update(arr => arr.map(x => x.id === id ? { ...x, done: true } : x)); // Update one
Enforcing Immutability
- Use
readonlytypes orDeepReadonlyhelpers so TypeScript flags accidental mutation. - Use
Object.freeze()in development to catch in-place writes early. - Prefer
set()/update()and never assign tosignal().propertydirectly.
// Freeze during development to catch accidental mutation
const state = signal(Object.freeze({ count: 0, items: [] as string[] }));
// state().count = 5; // ❌ Throws in strict mode - caught immediately!
state.update(s => Object.freeze({ ...s, count: 5 })); // ✅ Correct
💡 Custom equality: If you truly need value-based comparison, pass an
equalfunction:signal(value, { equal: (a, b) => deepEqual(a, b) }). Use sparingly — reference equality is faster and immutability keeps it predictable.
�🧮 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
}
}
🔗 linkedSignal — Writable Derived State
linkedSignal (stable since Angular 19) creates a signal that is derived from other signals but is also writable. Think of it as a computed you can override: it recomputes when its source changes, yet you can still set()/update() it locally until the next source change resets it.
The Problem It Solves
A computed is read-only, so you can't let the user change it. A plain signal doesn't auto-reset when the source data changes. linkedSignal gives you both.
import { signal, linkedSignal } from '@angular/core';
// Source of truth: the list of available shipping options
const shippingOptions = signal(['Standard', 'Express', 'Overnight']);
// selectedOption follows the list, but the user can also change it
const selectedOption = linkedSignal(() => shippingOptions()[0]);
console.log(selectedOption()); // 'Standard'
// ✅ User picks a different option (writable!)
selectedOption.set('Express');
console.log(selectedOption()); // 'Express'
// 🔄 When the source list changes, the selection RESETS to the computed default
shippingOptions.set(['Economy', 'Priority']);
console.log(selectedOption()); // 'Economy' — automatically reset
Advanced Form: Access to Previous State
The object form gives you both the current source and the previous computed/value, so you can preserve a valid selection across source changes.
@Component({
selector: 'app-shipping',
template: `
<select [value]="selected()" (change)="selected.set($any($event.target).value)">
@for (opt of options(); track opt.id) {
<option [value]="opt.id">{{ opt.label }}</option>
}
</select>
<p>Selected: {{ selected() }}</p>
`
})
export class ShippingComponent {
options = signal([
{ id: 1, label: 'Standard' },
{ id: 2, label: 'Express' },
]);
// Keep the user's choice if it still exists after the options change,
// otherwise fall back to the first option.
selected = linkedSignal<{ id: number; label: string }[], number>({
source: this.options,
computation: (newOptions, previous) => {
const stillValid = previous && newOptions.some(o => o.id === previous.value);
return stillValid ? previous!.value : newOptions[0].id;
},
});
}
linkedSignal vs computed vs signal
| Feature | signal | computed | linkedSignal |
|---|---|---|---|
| Holds its own value | ✅ | ❌ (derived) | ✅ |
Writable (set/update) | ✅ | ❌ read-only | ✅ |
| Auto-updates from sources | ❌ | ✅ | ✅ |
| Resets local changes on source change | ❌ | n/a | ✅ |
| Typical use | Base state | Derived read-only values | Editable defaults that follow source data |
💡 Rule of thumb: reach for
linkedSignalwhen a value should default from other state but remain user-editable — e.g. a selected dropdown item, a quantity clamped to available stock, or a draft that resets when the underlying record changes.
⚡ 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:
afterRenderrenamed toafterEveryRender(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 useset()andupdate() - Computed signals:
computed()- read-only, memoized, derived - Linked signals:
linkedSignal()- writable and derived; resets when its source changes - Read-only signals:
signal().asReadonly()- prevent external modification
Immutability:
- Never mutate in place - signals use reference equality, so in-place edits go undetected
- Always return new objects/arrays -
{ ...obj },[...arr]inupdate() - Spread at every nested level you change;
Object.freeze()in dev catches mistakes early
Effects Rules:
- ✅ Use for side effects: logging, API calls, DOM manipulation, localStorage
- ❌ DON'T change signals: No
set(),update(), ormutate()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:
afterNextRenderandafterEveryRenderare 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! 🚀