Reactivity advanced

Signal Reactivity Deep Dive

Understanding the Diamond Problem, dependency tracking, and glitch-free execution in Angular Signals.

Alex Muturi
May 29, 2025
22 min read
#signals #reactivity #performance #computed #effects #dependency-tracking #diamond-problem

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:

  1. Multiple unnecessary recomputations
  2. Inconsistent intermediate states (glitches)
  3. 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:

  1. derived computes exactly once per change
  2. ✅ All dependencies (left, right) have consistent values
  3. ✅ No glitches or intermediate states
  4. ✅ 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:

  1. Push phase: Signal changes are pushed to mark consumers as dirty
  2. 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

  1. Diamond Problem Solution: Angular Signals use topological ordering and lazy evaluation to guarantee each computed signal executes exactly once per change cycle.

  2. Automatic Dependency Tracking: Dependencies are tracked dynamically during execution, allowing for conditional dependency graphs.

  3. Glitch-Free Execution: The push-pull model ensures all intermediate values are consistent before effects run.

  4. Performance: Lazy evaluation means unused computed signals never execute, and caching prevents redundant computation.

  5. Debugging: Understanding the dependency graph and execution order helps debug complex reactive chains.

Next Steps

References

Share this article