Handling Side Effects & Working with the useEffect() HookEntry 5 of 7
Cleanup Functions
A function returned from an effect that undoes its work: clearing timers, removing listeners, or closing connections.
Notes
If the effect function returns a function, React treats it as the cleanup. Cleanup runs:
- Before the effect runs again, when a dependency changes.
- When the component unmounts (is removed from the page).
Without cleanup, timers keep firing and listeners keep running after the component is gone, causing memory leaks and updates to components that no longer exist.
What / Why / When
- What
Teardown code paired with each effect run.
- Why
Prevents leaks and stale callbacks.
- When
The effect starts anything that keeps running: timers, intervals, subscriptions, event listeners, sockets.
- Use case
A delete-confirmation dialog that auto-confirms after 3 seconds and shows a countdown bar.
Code compare
import { useEffect, useState } from "react";const TIMER = 3000;type DeleteConfirmationProps = { onConfirm: () => void; onCancel: () => void;};export function DeleteConfirmation({ onConfirm, onCancel }: DeleteConfirmationProps) { // Auto-confirm after TIMER ms useEffect(() => { const timeout = setTimeout(onConfirm, TIMER); return () => clearTimeout(timeout); // runs on unmount or before the next run }, [onConfirm]); return ( <div id="delete-confirmation"> <h2>Are you sure?</h2> <button onClick={onCancel}>No</button> <button onClick={onConfirm}>Yes</button> <ProgressBar timer={TIMER} /> </div> );}// Separate component: only the bar re-renders every 10 ms, not the whole dialogfunction ProgressBar({ timer }: { timer: number }) { const [remaining, setRemaining] = useState(timer); useEffect(() => { const interval = setInterval(() => setRemaining((prev) => prev - 10), 10); return () => clearInterval(interval); }, []); return <progress value={remaining} max={timer} />;}Two options, depending on scope:
- Component-scoped:
DestroyRef.onDestroy()(orngOnDestroy) runs once, when the component is destroyed. - Effect-scoped: the
onCleanupcallback insideeffect()runs before each re-run and on destroy, exactly like React's cleanup.
import { Component, DestroyRef, inject, output, signal } from "@angular/core";const TIMER = 3000;@Component({ selector: "app-delete-confirmation", template: ` <div id="delete-confirmation"> <h2>Are you sure?</h2> <button (click)="cancel.emit()">No</button> <button (click)="confirm.emit()">Yes</button> <progress [value]="remaining()" [max]="timer"></progress> </div> `,})export class DeleteConfirmationComponent { confirm = output<void>(); cancel = output<void>(); protected timer = TIMER; protected remaining = signal(TIMER); constructor() { const timeout = setTimeout(() => this.confirm.emit(), TIMER); const interval = setInterval(() => this.remaining.update((r) => r - 10), 10); inject(DestroyRef).onDestroy(() => { clearTimeout(timeout); clearInterval(interval); }); }}Effect-scoped cleanup, for work that restarts when a signal changes:
effect((onCleanup) => { const delay = this.delay(); // tracked dependency const id = setInterval(() => console.log("tick"), delay); onCleanup(() => clearInterval(id)); // before the next run and on destroy});Differences and gotchas
- Your notes compared cleanup to
ngOnDestroy. That's true on unmount, but React's cleanup also runs before every re-run of the effect, not just once. The exact Angular match isonCleanupinsideeffect(). - In Angular, the progress bar doesn't need its own component: updating the
remainingsignal refreshes only the[value]binding. In React, isolating the interval inProgressBarkeeps the rest of the dialog from re-rendering every 10 ms.
Quick reference
| React | Angular |
|---|---|
return () => cleanup() in useEffect | onCleanup(() => ...) in effect() |
Cleanup on unmount only ([]) | DestroyRef.onDestroy() / ngOnDestroy |