Angular Signals provide a reactive way to manage state and update application interfaces when data changes. They help developers track dependencies explicitly, reduce unnecessary computations, and build components that respond to state changes without relying exclusively on traditional change-detection patterns.
However, reactive code introduces its own challenges. When an effect starts a timer, registers an event listener, launches a request, or subscribes to an external resource, that operation may continue after the state that created 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.
This is different from onCleanup() inside an effect.
Requirement | Recommended mechanism |
|---|
Release a resource before an effect reruns | Effect onCleanup() |
Release a resource when a component or service is destroyed | DestroyRef.onDestroy()
|
Automatically clean up an Angular-managed subscription | Appropriate RxJS integration, such as takeUntilDestroyed() |
Derive a value from other signals | computed()
|
Perform a user-triggered operation | An event handler or explicit method |
Choosing the correct lifecycle mechanism makes ownership clearer and reduces accidental resource leaks.
Managing RxJS Subscriptions With Angular Signals
Angular applications often use both Signals and RxJS.
Signals are useful for representing reactive state, while RxJS provides operators and abstractions for asynchronous streams, event sequences, cancellation, and complex request coordination.
If a subscription is created inside an effect, its cleanup should unsubscribe when that effect execution becomes obsolete.
For example:
import { Component, effect, signal } from '@angular/core';
import { interval } from 'rxjs';
@Component({
selector: 'app-stream-monitor',
standalone: true,
template: `
<button (click)="toggleMonitoring()">
Toggle Monitoring
</button>
`
})
export class StreamMonitorComponent {
monitoringEnabled = signal(true);
constructor() {
effect((onCleanup) => {
if (!this.monitoringEnabled()) {
return;
}
const subscription = interval(1000).subscribe(value => {
console.log('Stream value:', value);
});
onCleanup(() => {
subscription.unsubscribe();
});
});
}
toggleMonitoring(): void {
this.monitoringEnabled.update(enabled => !enabled);
}
}
When monitoring is disabled, the effect reruns and cleans up the previous subscription. When the component is destroyed, the subscription associated with the active effect execution is also released.
For subscriptions that belong to a component's lifecycle rather than to an effect run, Angular's takeUntilDestroyed() can be a better fit.
For example:
import {
Component,
DestroyRef,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-lifecycle-stream',
standalone: true,
template: `<p>Monitoring component lifecycle.</p>`
})
export class LifecycleStreamComponent {
private readonly destroyRef = inject(DestroyRef);
constructor() {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
console.log('Stream value:', value);
});
}
}
The subscription is automatically completed when the associated destruction context is destroyed.
The key distinction is ownership: an effect-scoped subscription should stop when its effect execution becomes obsolete, while a component-scoped subscription should stop when the component is destroyed.
Avoiding Common Effect Mistakes
Using Effects to Copy Derived State
A common mistake is creating an effect just to copy a signal's value into another signal.
For example:
// Avoid this pattern for simple derived state.
effect(() => {
this.total.set(this.price() * this.quantity());
});
When total is entirely determined by price and quantity, use a computed signal:
total = computed(
() => this.price() * this.quantity()
);
This expresses the dependency directly and avoids maintaining redundant state.
Creating Effects Outside an Appropriate Injection Context
Angular normally expects effects to be created within an injection context, such as a component or service constructor, unless an appropriate Injector is supplied explicitly.
Creating effects in arbitrary functions without managing their lifecycle can make cleanup difficult or lead to errors.
Create effects in a lifecycle scope that clearly owns them. When an effect must be created outside the usual injection context, configure its injector appropriately and manage its destruction deliberately.
Assuming Every Effect Runs Immediately
Effect execution is coordinated by Angular's reactive runtime and can depend on the context in which the effect was created.
Do not design application correctness around assumptions about an effect running synchronously at the exact moment a signal is updated.
Use explicit event handlers for operations that must happen as a direct consequence of a user action, and use effects for synchronization with external systems.
Performing Expensive Work on Every Signal Change
An effect that performs expensive calculations, repeatedly initializes a third-party library, or triggers unnecessary network requests can reduce application responsiveness.
Keep effect responsibilities focused. Use computed signals for derived calculations, debounce or otherwise coordinate rapidly changing inputs where appropriate, and avoid recreating resources unnecessarily.
Ignoring Errors in Asynchronous Operations
An effect that starts a promise does not automatically handle every rejection from that promise.
Use appropriate error handling for asynchronous work. Distinguish cancellation from genuine failures, and ensure that error handling does not accidentally hide operational problems.
Best Practices for Production Angular Applications
Follow these practices when working with Signals and effects:
- Use
computed() for derived state instead of copying values through effects. - Use effects primarily to synchronize reactive state with external systems.
- Register
onCleanup() whenever an effect creates a resource that must be released before rerunning. - Use
DestroyRef for resources whose lifetime matches a component or service. - Cancel obsolete requests when the underlying API supports cancellation.
- Prevent stale asynchronous results from overwriting current state.
- Unsubscribe from effect-scoped RxJS streams when their effect execution becomes obsolete.
- Prefer Angular-managed template bindings over manual DOM event listeners when practical.
- Avoid network calls and expensive operations that do not need to run for every state change.
- Test cleanup behavior when signals change and when components are destroyed.
- Keep resource ownership explicit so that the code responsible for creating a resource also defines how it is released.
These practices help make reactive code easier to reason about and reduce unnecessary background activity.
Advantages and Disadvantages
Advantages
- Prevents obsolete timers and event listeners from remaining active.
- Allows asynchronous operations to be cancelled when their results are no longer relevant.
- Keeps reactive resources aligned with signal dependencies.
- Reduces unnecessary network requests and background processing.
- Makes component and effect lifecycle behavior easier to manage.
Disadvantages
- Requires developers to identify which resources need explicit cleanup.
- Cancellation does not necessarily undo operations already completed on a server.
- Incorrect cleanup can remove the wrong listener or release a resource still needed elsewhere.
- Mixing component-level and effect-level lifecycles can make resource ownership unclear.
- Complex asynchronous workflows may require RxJS or a dedicated service rather than a single effect.
Conclusion
Angular Signals make reactive state management more explicit, but effects still require careful resource management. When an effect creates a timer, event listener, subscription, or asynchronous request, that resource can outlive the state that originally created it unless cleanup is implemented correctly.
The onCleanup() callback is the appropriate mechanism for releasing resources before an effect reruns and when it is destroyed. DestroyRef is useful for resources tied to a component or service lifecycle, while RxJS provides additional tools for managing streams and asynchronous workflows.
The most reliable approach is to keep derived state in computed() signals, use effects for interactions with external systems, and define clear ownership for every resource. By combining reactive dependency tracking with explicit cleanup, developers can prevent unwanted side effects and build Angular applications that remain predictable as their state changes.
Join the conversation! Your thoughts help the community grow.