| name | effect-safety-validator |
| description | Detect and fix unsafe effect patterns in Angular signals including constructor effects, form mutations, observable subscriptions in effects, and feedback loops. Use when fixing infinite loops, detecting ObjectUnsubscribedError, preventing form mutation side effects, or validating effect safety patterns. Do NOT use for general signal migration, simple component testing, or non-effect-related Angular issues. |
| triggers | ["infinite loop","effect loop","feedback loop","ObjectUnsubscribedError","NG0101","recursive tick","Maximum call stack","form patchValue","emitEvent","untracked","effect safety","subscription leak","takeUntilDestroyed","queueMicrotask","effect cleanup"] |
| negative_triggers | ["create test","write test","CSS","layout","Python","backend","simple signal","input signal"] |
Effect Safety Validator Skill
Expert guidance for identifying and fixing unsafe Angular effect patterns, preventing infinite loops, and ensuring proper effect lifecycle management.
Critical Effect Patterns
Subscription Cleanup in Effects
CRITICAL: All subscriptions in effects MUST be properly cleaned up
effect(() => {
this.service.getData().subscribe(data => {
this.$data.set(data);
});
});
effect(() => {
const subscription = this.service.getData().subscribe(data => {
this.$data.set(data);
});
return () => subscription.unsubscribe();
}, { manualCleanup: true });
effect(() => {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
});
Form Mutations in Effects (Feedback Loop Prevention)
CRITICAL: Form mutations in effects MUST use {emitEvent: false} to prevent infinite loops
effect(() => {
const value = this.$selectedValue();
this.form.patchValue({ field: value });
});
effect(() => {
const value = this.$selectedValue();
this.form.patchValue({ field: value }, { emitEvent: false });
});
Anti-Pattern: Observable in Effect
CRITICAL: Don't call observable-returning methods inside effects without subscription
effect(() => {
this.service.getData();
});
effect(() => {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
});
$data = toSignal(
this.service.getData(),
{ initialValue: [] }
);
Unsafe Constructor Effect Pattern
Pattern: Initializing data in constructor effect
constructor() {
effect(() => {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
});
}
$data = toSignal(this.service.getData(), { initialValue: [] });
ngOnInit() {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
}
$data = signal<Data[]>([]);
private initData = effect(() => {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
});
Infinite Loop Patterns
Pattern 1: Signal Mutation in Effect (Self-triggering)
effect(() => {
const count = this.$count();
this.$count.set(count + 1);
});
effect(() => {
const count = this.$count();
this.$displayValue.set(count * 2);
});
Pattern 2: Form Value Change Loop
effect(() => {
const value = this.form.get('field')?.value;
this.$selectedValue.set(value);
});
this.form.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
effect(() => {
this.form.patchValue({ ...value });
});
});
effect(() => {
const selected = this.$selectedValue();
this.form.patchValue({ field: selected }, { emitEvent: false });
});
Pattern 3: Array/Object Mutation Loop
effect(() => {
const items = this.$items();
items.forEach(item => {
item.updated = true;
});
});
effect(() => {
const items = this.$items();
const updated = items.map(item => ({ ...item, updated: true }));
this.$processedItems.set(updated);
});
Detection Checklist
For each effect, verify:
- No self-referential mutations: Effect depends on signal A, doesn't mutate A
- Proper subscription cleanup: Uses onCleanup or takeUntilDestroyed
- Form mutations safe: All form.patchValue() use
{emitEvent: false}
- Observable subscription present: Observable-returning calls are subscribed to
- No multiple effects mutating same signal: Would create potential loops
- Data flow direction clear: Effects flow data in one direction
- No circular dependencies: Signal A -> Effect updates B; not B -> Effect updates A
- Initialization pattern safe: No heavy initialization in constructor effects
Refactoring Unsafe Effects
Unsafe: Constructor Effect with Service
export class DataComponent {
private readonly $data = signal<Data[]>([]);
constructor(private service: DataService) {
effect(() => {
this.service.getData().subscribe(data => {
this.$data.set(data);
});
});
}
}
Safe: Use toSignal
export class DataComponent {
protected readonly $data = toSignal(
inject(DataService).getData(),
{ initialValue: [] }
);
}
Safe: Use Field Initializer
export class DataComponent {
private readonly service = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
protected readonly $data = signal<Data[]>([]);
private initData = effect(() => {
this.service.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.$data.set(data));
});
}
Verification and Testing
Static Analysis
grep -r "effect(" --include="*.ts" client/src/app/
grep -r "\.subscribe(" --include="*.ts" client/src/app/ | grep "effect"
grep -r "patchValue" --include="*.ts" client/src/app/ | grep -v "emitEvent"
Testing Effect Safety
it('should not create infinite loop', () => {
fixture.detectChanges();
TestBed.flushEffects();
expect(component['$data']()).toBeDefined();
});
it('should cleanup subscriptions', () => {
const subscription = component['dataSubscription'];
component.ngOnDestroy();
expect(subscription.closed).toBe(true);
});
Angular 20 Breaking Changes
See references/angular20-migration.md for detailed patterns with untracked(), queueMicrotask(), and the migration checklist.
Key rule: Effects with change detection triggers (BehaviorSubject, router, form mutations, HTTP subscriptions) now throw NG0101. Wrap side effects in untracked() and signal updates in queueMicrotask().
Examples
User says: "My component has an infinite loop after migration"
- Search for
effect( in the component -- find 3 effects
- Check each: does it mutate a signal it depends on? -> Effect #2 does: reads
$count() and sets $count.set()
- Fix: separate dependency tracking from mutation, use different signal for output
- Verify:
fixture.detectChanges() + TestBed.flushEffects() completes without timeout
User says: "Getting ObjectUnsubscribedError in production"
- Find effect with subscription:
effect(() => { this.service.getData().subscribe(...) })
- Check cleanup: no
takeUntilDestroyed or onCleanup -> subscription leaks
- Fix: add
.pipe(takeUntilDestroyed(this.destroyRef)) before .subscribe()
- Verify: component destroy cleans up subscription
User says: "NG0101 error after Angular 20 upgrade"
- Find effect triggering change detection:
this.loading$.next(true) inside effect
- Fix: wrap in
untracked(() => { this.loading$.next(true); })
- For HTTP subscriptions with signal updates: wrap in
queueMicrotask()
- Verify: no NG0101 in console
Common Issues
| Issue | Cause | Solution |
|---|
Maximum call stack exceeded | Effect mutates the signal it depends on | Use separate signals for input/output of effect |
ObjectUnsubscribedError | Subscription outlives component lifecycle | Add takeUntilDestroyed(this.destroyRef) |
NG0101: tick is called recursively | Effect triggers change detection (Angular 20) | Wrap side effects in untracked() |
| Form patchValue triggers infinite loop | Missing {emitEvent: false} | Add {emitEvent: false} to ALL patchValue in effects |
See Also
- signal-patterns — General signal/computed/effect patterns, mutation detection, parent-child communication
- test-creation — Testing effects safely (NG0101 avoidance, flushEffects patterns)