When data changes, Angular Signals offer a reactive method for managing state and updating application interfaces. Without depending solely on conventional change-detection techniques, they assist developers in clearly tracking dependencies, cutting down on pointless calculations, and creating components that react to state changes.
Reactive coding, however, presents unique difficulties. When an effect initiates a timer, registers an event listener, initiates a request, or subscribes to an external resource, it may go on even after the state that initiated it is no longer relevant.

For example, an Angular component might use an effect to start polling an API whenever a selected customer changes. If the previous polling operation is not stopped, multiple timers could remain active. These timers might issue unnecessary requests, consume resources, or update state using outdated results.
Angular provides cleanup mechanisms to help manage these situations. Understanding when an effect reruns, how onCleanup() works, and when to use DestroyRef or RxJS is essential for writing predictable reactive applications.
This article explains how Angular Signals effects work, how to clean up side effects correctly, and how to avoid common lifecycle and asynchronous programming problems.
Understanding Angular Signals
A signal is a reactive value that Angular can track when application code reads it. When the signal changes, Angular can notify the relevant consumers that depend on it.
Angular provides three important signal-related concepts:
- signal() creates a writable signal.
- computed() derives a value from other signals.
- effect() runs code that reacts to signal dependencies.
Consider a component that tracks a selected product and its quantity:
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-product-summary',
standalone: true,
template: `
<h2>Product Summary</h2>
<p>Quantity: {{ quantity() }}</p>
<p>Total items: {{ totalItems() }}</p>
<button (click)="increaseQuantity()">Add Item</button>
`
})
export class ProductSummaryComponent {
quantity = signal(1);
itemsPerOrder = signal(3);
totalItems = computed(
() => this.quantity() * this.itemsPerOrder()
);
increaseQuantity(): void {
this.quantity.update(value => value + 1);
}
}
In this example, quantity and itemsPerOrder are writable signals. The totalItems computed signal derives its value from both.
When either dependency changes, Angular can recalculate the computed value when it is needed.
Notice that computed() is used for a derived value rather than an effect. This distinction matters because derived state should generally remain a computation, not an operation that introduces additional side effects.
What Is an Effect?
An effect runs a function that reads signals and performs an operation in response to changes in those signals.
For example:
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-search-settings',
standalone: true,
template: `
<button (click)="changeSearchTerm()">Change Search</button>
`
})
export class SearchSettingsComponent {
searchTerm = signal('Angular');
constructor() {
effect(() => {
console.log('Current search term:', this.searchTerm());
});
}
changeSearchTerm(): void {
this.searchTerm.set('Angular Signals');
}
}
The effect reads searchTerm(), so Angular tracks that signal as a dependency. When the signal changes, the effect can run again.
Effects are useful when reactive state must interact with an imperative API, such as browser storage, logging, or a third-party library.
They should not be the default mechanism for every reactive operation. For example, using computed() for derived state is generally clearer than using an effect to copy one signal's value into another.
Why Effect Cleanup Matters
An effect does not automatically know how to undo every operation performed by its callback.
Consider this example:
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-polling',
standalone: true,
template: `
<button (click)="changeCustomer()">Change Customer</button>
`
})
export class PollingComponent {
customerId = signal(1);
constructor() {
effect(() => {
const customerId = this.customerId();
setInterval(() => {
console.log('Polling customer:', customerId);
}, 5000);
});
}
changeCustomer(): void {
this.customerId.update(id => id + 1);
}
}
This code contains a resource-management problem.
Every time customerId changes, the effect can execute again and create another interval. The previous interval continues running because nothing clears it.
After several customer changes, multiple timers may be active simultaneously.
The effect's reactive dependency tracking does not automatically clean up arbitrary resources created by its callback.
To handle this, Angular provides the onCleanup callback.
Using onCleanup() to Release Resources
Angular's effect callback can receive an onCleanup function. Registering a cleanup callback allows Angular to execute the cleanup before the effect runs again and when the effect is destroyed.
The general structure is:
effect((onCleanup) => {
// Read signals and perform an operation.
onCleanup(() => {
// Release resources created by this effect run.
});
});
The cleanup function should undo or release resources associated with that particular effect execution.
Fixing the Polling Example
The previous example can be corrected by clearing the interval:
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-polling',
standalone: true,
template: `
<button (click)="changeCustomer()">Change Customer</button>
`
})
export class PollingComponent {
customerId = signal(1);
constructor() {
effect((onCleanup) => {
const customerId = this.customerId();
const timerId = setInterval(() => {
console.log('Polling customer:', customerId);
}, 5000);
onCleanup(() => {
clearInterval(timerId);
});
});
}
changeCustomer(): void {
this.customerId.update(id => id + 1);
}
}
Now, when the effect reruns because customerId changes, Angular executes the cleanup associated with the previous run before proceeding with the new execution.
The previous interval is cleared, and the new run creates one interval for the selected customer.
When the effect is destroyed, Angular also runs its registered cleanup.
This prevents obsolete timers from continuing to execute.
Important Cleanup Rules
When using onCleanup(), follow these principles:
- Register cleanup immediately after creating the resource whenever practical.
- Capture the resource identifier or handle belonging to the current effect run.
- Make sure cleanup releases only the resource owned by that run.
- Do not assume cleanup runs every time a signal changes; it runs when the effect is scheduled to rerun and when the effect is destroyed.
- Avoid placing unrelated application logic inside cleanup callbacks.
Cleanup should be predictable and limited to releasing resources, cancelling work, or restoring the state associated with the previous execution.
Cleaning Up Event Listeners
Event listeners are another common source of leaks in reactive applications.
Suppose an effect registers a browser event listener whenever a signal changes:
effect((onCleanup) => {
const eventName = this.eventName();
const handler = (event: Event) => {
console.log(eventName, event.type);
};
window.addEventListener(eventName, handler);
onCleanup(() => {
window.removeEventListener(eventName, handler);
});
});
The cleanup callback removes the exact listener created by that effect execution.
This detail is important because removeEventListener() must receive a matching event type, listener function, and compatible options.
Creating a new anonymous function inside the cleanup callback would not remove the original listener:
// Incorrect: this is a different function reference.
window.addEventListener('resize', () => {
console.log('Window resized');
});
window.removeEventListener('resize', () => {
console.log('Window resized');
});
The two arrow functions are different function objects. The removal operation therefore does not unregister the original listener.
Store the handler reference and use the same reference for registration and removal.
For component-specific DOM interactions, Angular's template event bindings are often simpler than manually registering browser listeners. Use manual listeners when the integration genuinely requires them.
Preventing Stale Asynchronous Requests
One of the most important uses of effect cleanup is cancelling obsolete asynchronous work.
Imagine a component that loads customer details whenever customerId changes. If the user switches from customer 1 to customer 2 before the first request finishes, the first response might arrive last.
Without appropriate handling, outdated data could overwrite the newer result.
An effect can use AbortController to cancel a previous fetch operation when the effect reruns or is destroyed.
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-customer-details',
standalone: true,
template: `
<p>Selected customer: {{ customerId() }}</p>
<button (click)="selectNextCustomer()">Next Customer</button>
`
})
export class CustomerDetailsComponent {
customerId = signal(1);
constructor() {
effect((onCleanup) => {
const customerId = this.customerId();
const controller = new AbortController();
onCleanup(() => {
controller.abort();
});
void fetch(`/api/customers/${customerId}`, {
signal: controller.signal
})
.then(response => {
if (!response.ok) {
throw new Error(
`Request failed with status ${response.status}`
);
}
return response.json();
})
.then(customer => {
console.log('Loaded customer:', customer);
})
.catch(error => {
if (error.name === 'AbortError') {
return;
}
console.error('Unable to load customer:', error);
});
});
}
selectNextCustomer(): void {
this.customerId.update(id => id + 1);
}
}
When customerId changes, Angular runs the cleanup for the previous effect execution, which aborts its request.
The new effect execution then creates a new controller and starts a request for the current customer.
Registering cleanup before starting the request ensures the cancellation mechanism is established as soon as the resource is created.
Cancellation Is Not a Universal Guarantee
Aborting a fetch request is useful, but cancellation does not undo server-side operations that have already completed.
For read requests, cancellation can prevent unnecessary client-side work and help avoid processing obsolete responses. For operations that modify server data, the server may already have received or processed the request.
Applications that perform mutations should use appropriate server-side transaction handling, idempotency controls, and business rules rather than relying on client cancellation alone.
Also, avoid updating component state from obsolete asynchronous operations when the underlying API cannot be cancelled. In those cases, use a request identifier or another explicit stale-result check.
Using DestroyRef for Component-Level Cleanup
Sometimes a resource belongs to the component rather than to an individual effect execution.
Angular provides DestroyRef for registering cleanup logic that runs when the associated lifecycle scope is destroyed.
For example, a component may register a listener that should remain active for the component's entire lifetime:
import {
Component,
DestroyRef,
inject
} from '@angular/core';
@Component({
selector: 'app-window-monitor',
standalone: true,
template: `<p>Window monitor is active.</p>`
})
export class WindowMonitorComponent {
private readonly destroyRef = inject(DestroyRef);
constructor() {
const handler = () => {
console.log('Window resized');
};
window.addEventListener('resize', handler);
this.destroyRef.onDestroy(() => {
window.removeEventListener('resize', handler);
});
}
}
The event listener is registered once and remains active until the component is destroyed.