Signal Reactivity Deep Dive
Understanding how Angular's reactivity system works under the hood is crucial for building performant applications and debugging complex reactive chains. This tutorial explores the Diamond Problem, dependency tracking, and glitch-free execution.
What You'll Learn
- How signals track dependencies automatically
- What the Diamond Problem is and how signals solve it
- Glitch-free execution guarantees
- Push-pull reactivity model
- Optimization strategies for reactive graphs
- Advanced debugging techniques
Prerequisites
Before starting this tutorial, you should be familiar with:
- Basic signals usage (
signal(),computed(),effect()) - Computed signals and derivation
- Effect lifecycle and cleanup
The Diamond Problem
What is the Diamond Problem?
The Diamond Problem occurs when a derived value depends on multiple paths from the same source, potentially causing:
- Multiple unnecessary recomputations
- Inconsistent intermediate states (glitches)
- Unexpected behavior in effects
Consider this dependency graph:
[Source]
/ \
[Left] [Right]
\ /
[Derived]
If Source changes, Derived depends on both Left and Right, which both depend on Source. Without proper handling, Derived might recompute twice or see inconsistent values.
Traditional Reactive Systems (Prone to Glitches)
// Problematic approach (NOT how Angular Signals work)
class NaiveReactive {
private value: number = 0;
private subscribers: Set<() => void> = new Set();
get() {
return this.value;
}
set(newValue: number) {
this.value = newValue;
// Problem: Immediately notifies all subscribers
// This causes cascading updates
this.subscribers.forEach(fn => fn());
}
subscribe(fn: () => void) {
this.subscribers.add(fn);
}
}
// This creates the Diamond Problem
const source = new NaiveReactive();
const left = new NaiveReactive();
source.subscribe(() => left.set(source.get() * 2));
const right = new NaiveReactive();
source.subscribe(() => right.set(source.get() + 10));
const derived = new NaiveReactive();
left.subscribe(() => derived.set(left.get() + right.get()));
right.subscribe(() => derived.set(left.get() + right.get()));
// Problem: When source changes, derived computes TWICE
// and might see inconsistent intermediate states!
source.set(5);
// 1. left updates to 10
// 2. derived computes: 10 + 10 = 20 (WRONG! right hasn't updated)
// 3. right updates to 15
// 4. derived computes: 10 + 15 = 25 (CORRECT, but computed twice)
How Angular Signals Solve It
Angular Signals use a push-pull model with lazy evaluation:
import { signal, computed, effect } from '@angular/core';
// Create the diamond dependency graph
const source = signal(0);
const left = computed(() => source() * 2);
const right = computed(() => source() + 10);
// This will ONLY compute once when source changes
const derived = computed(() => {
console.log('Computing derived...');
return left() + right();
});
// Set up an effect to observe the result
effect(() => {
console.log('Derived value:', derived());
});
// Change the source
console.log('=== Setting source to 5 ===');
source.set(5);
// Output:
// Computing derived...
// Derived value: 25
// (Only computed ONCE, with consistent values)
console.log('=== Setting source to 10 ===');
source.set(10);
// Output:
// Computing derived...
// Derived value: 40
// (Again, only computed ONCE)
Key Points:
- ✅
derivedcomputes exactly once per change - ✅ All dependencies (
left,right) have consistent values - ✅ No glitches or intermediate states
- ✅ Efficient execution order
Dependency Tracking
Automatic Dependency Discovery
Signals automatically track which computeds and effects depend on them:
import { signal, computed, effect } from '@angular/core';
import { Component } from '@angular/core';
@Component({
selector: 'app-dependency-tracking',
standalone: true,
template: `
<div>
<h3>Dependency Tracking Demo</h3>
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<p>Conditional: {{ conditionalValue() }}</p>
<button (click)="increment()">Increment</button>
<button (click)="toggleUseDouble()">Toggle Condition</button>
<div class="debug">
<p>Use Double: {{ useDouble() }}</p>
<p>Effect runs: {{ effectRuns() }}</p>
</div>
</div>
`,
styles: [`
.debug {
margin-top: 20px;
padding: 10px;
background: #f0f0f0;
border-radius: 4px;
}
button {
margin: 5px;
padding: 8px 16px;
}
`]
})
export class DependencyTrackingComponent {
count = signal(0);
useDouble = signal(true);
effectRuns = signal(0);
// Always depends on count
double = computed(() => {
console.log('Computing double');
return this.count() * 2;
});
// Dependencies change dynamically based on condition
conditionalValue = computed(() => {
console.log('Computing conditionalValue');
if (this.useDouble()) {
// When true: depends on count AND double
return this.double();
} else {
// When false: depends ONLY on count
return this.count();
}
});
constructor() {
// Effect automatically tracks its dependencies
effect(() => {
console.log('Effect running');
console.log('Conditional value is:', this.conditionalValue());
this.effectRuns.update(n => n + 1);
});
}
increment() {
this.count.update(n => n + 1);
// When useDouble is true:
// - count changes
// - double recomputes
// - conditionalValue recomputes
// - effect runs
// When useDouble is false:
// - count changes
// - double still recomputes (has its own dependency)
// - conditionalValue recomputes (only reads count)
// - effect runs
}
toggleUseDouble() {
this.useDouble.update(v => !v);
// This changes the dependency graph of conditionalValue!
// - If switching to true: adds dependency on double
// - If switching to false: removes dependency on double
}
}
Dynamic Dependencies:
- Dependencies are tracked during execution
- Conditional branches create dynamic dependency graphs
- Only accessed signals become dependencies
Understanding the Dependency Graph
import { signal, computed } from '@angular/core';
const a = signal(1);
const b = signal(2);
const condition = signal(true);
// Complex dependency graph
const c = computed(() => a() * 2);
const d = computed(() => b() + 10);
const e = computed(() => {
// Dynamic dependencies based on condition
if (condition()) {
return c() + a(); // Depends on: condition, c, a
} else {
return d() + b(); // Depends on: condition, d, b
}
});
const f = computed(() => {
return e() * c(); // Depends on: e, c
});
// Visualize the current dependency graph
console.log('=== Initial State ===');
console.log('condition:', condition()); // true
console.log('e:', e()); // (1*2) + 1 = 3
console.log('f:', f()); // 3 * (1*2) = 6
// Change a - affects c, e, and f
console.log('\n=== Change a to 5 ===');
a.set(5);
console.log('e:', e()); // (5*2) + 5 = 15
console.log('f:', f()); // 15 * (5*2) = 150
// Change condition - changes dependency graph of e
console.log('\n=== Toggle condition ===');
condition.set(false);
console.log('e:', e()); // (2+10) + 2 = 14
console.log('f:', f()); // 14 * (5*2) = 140
// Now a change doesn't affect e (different branch)
console.log('\n=== Change a to 10 (condition is false) ===');
a.set(10);
console.log('e:', e()); // Still 14 (doesn't depend on a in this branch)
console.log('f:', f()); // 14 * (10*2) = 280 (f depends on c which depends on a)
Glitch-Free Execution
Topological Ordering
Angular Signals guarantee glitch-free execution through topological sorting:
import { signal, computed, effect } from '@angular/core';
// Complex multi-level dependency graph
const temperature = signal(20); // Celsius
// Level 1: Direct conversions
const fahrenheit = computed(() => {
console.log(' Computing fahrenheit');
return (temperature() * 9/5) + 32;
});
const kelvin = computed(() => {
console.log(' Computing kelvin');
return temperature() + 273.15;
});
// Level 2: Derived from Level 1
const temperatureStatus = computed(() => {
console.log(' Computing temperatureStatus');
const f = fahrenheit();
if (f < 32) return 'Freezing';
if (f < 70) return 'Cold';
if (f < 90) return 'Warm';
return 'Hot';
});
const scientificCategory = computed(() => {
console.log(' Computing scientificCategory');
const k = kelvin();
if (k < 273.15) return 'Below freezing point';
if (k < 373.15) return 'Liquid water range';
return 'Above boiling point';
});
// Level 3: Depends on multiple Level 2 computeds
const summary = computed(() => {
console.log(' Computing summary');
return `${temperature()}°C is ${temperatureStatus()} (${scientificCategory()})`;
});
// Effect observes the summary
effect(() => {
console.log('Effect:', summary());
});
console.log('=== Initial state ===');
console.log(summary());
console.log('\n=== Change temperature to 0 ===');
temperature.set(0);
// Guaranteed execution order:
// 1. fahrenheit and kelvin (Level 1)
// 2. temperatureStatus and scientificCategory (Level 2)
// 3. summary (Level 3)
// 4. effect
Output:
=== Initial state ===
Computing fahrenheit
Computing kelvin
Computing temperatureStatus
Computing scientificCategory
Computing summary
Effect: 20°C is Cold (Liquid water range)
20°C is Cold (Liquid water range)
=== Change temperature to 0 ===
Computing fahrenheit
Computing kelvin
Computing temperatureStatus
Computing scientificCategory
Computing summary
Effect: 0°C is Freezing (Below freezing point)
Push-Pull Hybrid Model
Angular Signals use a sophisticated push-pull model:
- Push phase: Signal changes are pushed to mark consumers as dirty
- Pull phase: Values are lazily recomputed only when read
import { signal, computed } from '@angular/core';
const base = signal(1);
const expensive = computed(() => {
console.log('💰 Expensive computation running...');
// Simulate expensive operation
let result = base();
for (let i = 0; i < 1000000; i++) {
result += Math.sin(i) * 0.0000001;
}
return result;
});
const dependent = computed(() => {
console.log('📊 Dependent computation');
return expensive() * 2;
});
console.log('=== Change base ===');
base.set(10);
// At this point, NO computations run!
// Both expensive and dependent are marked as "dirty"
console.log('Signal changed, but nothing computed yet');
console.log('\n=== Read dependent ===');
const value = dependent();
// NOW computations run:
// 1. expensive computes (pull triggered)
// 2. dependent computes (pull triggered)
console.log('Value:', value);
console.log('\n=== Read dependent again ===');
const value2 = dependent();
// Cached! No recomputation
console.log('Value:', value2);
console.log('\n=== Change base again ===');
base.set(20);
// Again, only marking as dirty
console.log('\n=== But never read it ===');
console.log('Expensive computation never ran!');
// If you never read dependent(), expensive() never recomputes
// This is the power of lazy evaluation
Performance Optimization Strategies
1. Minimize Dependency Chains
import { signal, computed } from '@angular/core';
// ❌ Bad: Long dependency chain
const step1 = signal(1);
const step2 = computed(() => step1() + 1);
const step3 = computed(() => step2() + 1);
const step4 = computed(() => step3() + 1);
const step5 = computed(() => step4() + 1);
// Changing step1 triggers 4 recomputations
// ✅ Good: Flatten when possible
const input = signal(1);
const result = computed(() => input() + 4);
// Direct computation, no intermediary steps
2. Share Common Computations
import { signal, computed } from '@angular/core';
const items = signal([1, 2, 3, 4, 5]);
// ❌ Bad: Recompute same values
const sum1 = computed(() => items().reduce((a, b) => a + b, 0));
const sum2 = computed(() => items().reduce((a, b) => a + b, 0));
const total = computed(() => sum1() + sum2()); // Computes sum twice!
// ✅ Good: Compute once, reuse
const sum = computed(() => items().reduce((a, b) => a + b, 0));
const doubleSum = computed(() => sum() * 2);
3. Use Proper Equality Functions
import { signal, computed } from '@angular/core';
interface User {
id: number;
name: string;
age: number;
}
// Without custom equality
const user = signal<User>({ id: 1, name: 'Alice', age: 30 });
// With custom equality (only recompute if id changes)
const userId = signal(
{ id: 1, name: 'Alice', age: 30 },
{ equal: (a, b) => a.id === b.id }
);
// This won't trigger recomputation (same id)
userId.set({ id: 1, name: 'Alice Smith', age: 31 });
4. Batch Related Updates
import { signal, computed, effect, untracked } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
const age = signal(30);
const userProfile = computed(() => ({
fullName: `${firstName()} ${lastName()}`,
age: age()
}));
effect(() => {
console.log('Profile updated:', userProfile());
});
// ❌ Bad: Multiple separate updates (effect runs 3 times)
firstName.set('Jane');
lastName.set('Smith');
age.set(25);
// ✅ Good: Create a batch update function
function updateUser(first: string, last: string, newAge: number) {
// Use a wrapper signal to batch
firstName.set(first);
lastName.set(last);
age.set(newAge);
// Effect still runs 3 times, but you could use a single object signal
}
// ✅ Better: Use a single signal for related data
const user = signal({
firstName: 'John',
lastName: 'Doe',
age: 30
});
const profile = computed(() => ({
fullName: `${user().firstName} ${user().lastName}`,
age: user().age
}));
// Now update all at once - effect runs once
user.set({ firstName: 'Jane', lastName: 'Smith', age: 25 });
Advanced Debugging Techniques
1. Tracking Computation Calls
import { signal, computed, effect } from '@angular/core';
function createTrackedComputed<T>(name: string, computation: () => T) {
let callCount = 0;
return computed(() => {
callCount++;
console.log(`[${name}] Computation #${callCount}`);
const result = computation();
console.log(`[${name}] Result:`, result);
return result;
});
}
const a = signal(1);
const b = signal(2);
const sum = createTrackedComputed('sum', () => a() + b());
const double = createTrackedComputed('double', () => sum() * 2);
const triple = createTrackedComputed('triple', () => sum() * 3);
console.log('=== Initial read ===');
console.log(double());
console.log('\n=== Change a ===');
a.set(5);
console.log(double());
2. Dependency Tree Visualization
import { signal, computed } from '@angular/core';
class SignalDebugger {
private static computedMap = new Map<string, { deps: string[], compute: () => any }>();
static registerComputed(name: string, deps: string[], compute: () => any) {
this.computedMap.set(name, { deps, compute });
return compute();
}
static visualizeGraph() {
console.log('=== Dependency Graph ===');
for (const [name, { deps }] of this.computedMap) {
console.log(`${name} → [${deps.join(', ')}]`);
}
}
static findDependents(signalName: string): string[] {
const dependents: string[] = [];
for (const [name, { deps }] of this.computedMap) {
if (deps.includes(signalName)) {
dependents.push(name);
}
}
return dependents;
}
}
const x = signal(10);
const y = signal(20);
const sum = computed(() =>
SignalDebugger.registerComputed('sum', ['x', 'y'], () => x() + y())
);
const product = computed(() =>
SignalDebugger.registerComputed('product', ['x', 'y'], () => x() * y())
);
const combined = computed(() =>
SignalDebugger.registerComputed('combined', ['sum', 'product'], () => sum() + product())
);
SignalDebugger.visualizeGraph();
// Output:
// sum → [x, y]
// product → [x, y]
// combined → [sum, product]
console.log('Dependents of x:', SignalDebugger.findDependents('x'));
// Output: Dependents of x: ['sum', 'product']
3. Effect Execution Tracking
import { signal, effect } from '@angular/core';
const count = signal(0);
const multiplier = signal(1);
let effectRunCount = 0;
const cleanup = effect((onCleanup) => {
effectRunCount++;
const executionId = effectRunCount;
console.log(`[Effect #${executionId}] Started`);
console.log(` count: ${count()}`);
console.log(` multiplier: ${multiplier()}`);
console.log(` result: ${count() * multiplier()}`);
onCleanup(() => {
console.log(`[Effect #${executionId}] Cleaned up`);
});
});
console.log('=== Update count ===');
count.set(5);
console.log('\n=== Update multiplier ===');
multiplier.set(3);
console.log('\n=== Update both ===');
count.set(10);
multiplier.set(2);
cleanup();
Real-World Example: Complex Form State
import { Component, signal, computed, effect } from '@angular/core';
interface FormField {
value: string;
touched: boolean;
dirty: boolean;
}
@Component({
selector: 'app-complex-form',
standalone: true,
template: `
<form>
<div>
<label>Username:</label>
<input
[value]="username().value"
(input)="updateUsername($event)"
(blur)="markTouched('username')"
/>
@if (usernameError()) {
<span class="error">{{ usernameError() }}</span>
}
</div>
<div>
<label>Email:</label>
<input
[value]="email().value"
(input)="updateEmail($event)"
(blur)="markTouched('email')"
/>
@if (emailError()) {
<span class="error">{{ emailError() }}</span>
}
</div>
<div>
<label>Password:</label>
<input
type="password"
[value]="password().value"
(input)="updatePassword($event)"
(blur)="markTouched('password')"
/>
@if (passwordError()) {
<span class="error">{{ passwordError() }}</span>
}
</div>
<div>
<label>Confirm Password:</label>
<input
type="password"
[value]="confirmPassword().value"
(input)="updateConfirmPassword($event)"
(blur)="markTouched('confirmPassword')"
/>
@if (confirmPasswordError()) {
<span class="error">{{ confirmPasswordError() }}</span>
}
</div>
<button
[disabled]="!formIsValid()"
(click)="submit()"
>
Submit
</button>
<div class="debug">
<h4>Form State Debug</h4>
<p>Valid: {{ formIsValid() }}</p>
<p>Dirty: {{ formIsDirty() }}</p>
<p>Touched: {{ formIsTouched() }}</p>
<p>Validation runs: {{ validationRuns() }}</p>
</div>
</form>
`,
styles: [`
.error { color: red; font-size: 12px; }
.debug { margin-top: 20px; padding: 10px; background: #f0f0f0; }
`]
})
export class ComplexFormComponent {
// Individual field signals
username = signal<FormField>({ value: '', touched: false, dirty: false });
email = signal<FormField>({ value: '', touched: false, dirty: false });
password = signal<FormField>({ value: '', touched: false, dirty: false });
confirmPassword = signal<FormField>({ value: '', touched: false, dirty: false });
validationRuns = signal(0);
// Field validation (Level 1)
usernameError = computed(() => {
this.validationRuns.update(n => n + 1);
const field = this.username();
if (!field.touched) return '';
if (field.value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-zA-Z0-9_]+$/.test(field.value)) return 'Username can only contain letters, numbers, and underscores';
return '';
});
emailError = computed(() => {
this.validationRuns.update(n => n + 1);
const field = this.email();
if (!field.touched) return '';
if (!field.value.includes('@')) return 'Invalid email format';
return '';
});
passwordError = computed(() => {
this.validationRuns.update(n => n + 1);
const field = this.password();
if (!field.touched) return '';
if (field.value.length < 8) return 'Password must be at least 8 characters';
return '';
});
confirmPasswordError = computed(() => {
this.validationRuns.update(n => n + 1);
const field = this.confirmPassword();
if (!field.touched) return '';
if (field.value !== this.password().value) return 'Passwords do not match';
return '';
});
// Form-level computed properties (Level 2)
formIsValid = computed(() => {
return !this.usernameError() &&
!this.emailError() &&
!this.passwordError() &&
!this.confirmPasswordError() &&
this.username().value.length > 0 &&
this.email().value.length > 0 &&
this.password().value.length > 0 &&
this.confirmPassword().value.length > 0;
});
formIsDirty = computed(() => {
return this.username().dirty ||
this.email().dirty ||
this.password().dirty ||
this.confirmPassword().dirty;
});
formIsTouched = computed(() => {
return this.username().touched ||
this.email().touched ||
this.password().touched ||
this.confirmPassword().touched;
});
constructor() {
// Auto-save effect (runs only when valid and dirty)
effect(() => {
if (this.formIsValid() && this.formIsDirty()) {
console.log('Auto-saving valid form data...');
// This effect smartly depends on formIsValid and formIsDirty
// which in turn depend on all fields
// Guaranteed glitch-free: all validations complete before effect runs
}
});
}
updateUsername(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.username.update(field => ({ ...field, value, dirty: true }));
}
updateEmail(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.email.update(field => ({ ...field, value, dirty: true }));
}
updatePassword(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.password.update(field => ({ ...field, value, dirty: true }));
// Note: This will trigger confirmPasswordError to revalidate!
}
updateConfirmPassword(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.confirmPassword.update(field => ({ ...field, value, dirty: true }));
}
markTouched(fieldName: 'username' | 'email' | 'password' | 'confirmPassword') {
this[fieldName].update(field => ({ ...field, touched: true }));
}
submit() {
if (this.formIsValid()) {
console.log('Submitting form:', {
username: this.username().value,
email: this.email().value,
password: this.password().value
});
}
}
}
Key Takeaways
Diamond Problem Solution: Angular Signals use topological ordering and lazy evaluation to guarantee each computed signal executes exactly once per change cycle.
Automatic Dependency Tracking: Dependencies are tracked dynamically during execution, allowing for conditional dependency graphs.
Glitch-Free Execution: The push-pull model ensures all intermediate values are consistent before effects run.
Performance: Lazy evaluation means unused computed signals never execute, and caching prevents redundant computation.
Debugging: Understanding the dependency graph and execution order helps debug complex reactive chains.
Next Steps
- Learn about Untracked Reads Pattern
- Explore Performance Optimization Best Practices
- Read Effect Cleanup Patterns