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:

  1. Before the effect runs again, when a dependency changes.
  2. 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

React
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() (or ngOnDestroy) runs once, when the component is destroyed.
  • Effect-scoped: the onCleanup callback inside effect() runs before each re-run and on destroy, exactly like React's cleanup.
Angular
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:

Angular
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 is onCleanup inside effect().
  • In Angular, the progress bar doesn't need its own component: updating the remaining signal refreshes only the [value] binding. In React, isolating the interval in ProgressBar keeps the rest of the dialog from re-rendering every 10 ms.

Quick reference

ReactAngular
return () => cleanup() in useEffectonCleanup(() => ...) in effect()
Cleanup on unmount only ([])DestroyRef.onDestroy() / ngOnDestroy