Properly cleaning up side effects is a crucial aspect of building reliable React applications. Just as astronauts must maintain their life support systems to avoid malfunctions, we too must take care of cleaning up our effects to avoid memory leaks, unnecessary computations, and other problems.
When a component is unmounted (removed from the DOM) or when effect dependencies change, various problems can occur if we don't take care of proper cleanup:
In useEffect, the cleanup function is returned from the effect function:
1useEffect(() => {
2 // Effect code - runs after rendering
3 console.log('Efekt uruchomiony');
4
5 // Cleanup function - runs before the next effect or on unmount
6 return () => {
7 console.log('Efekt wyczyszczony');
8 };
9}, [dependency]); // Dependency arrayThe cleanup function is invoked in two situations:
1function SpaceModule({ moduleId }) {
2 useEffect(() => {
3 console.log(`Module ${moduleId} has been initialized`);
4
5 // This function will be called:
6 // 1. Before the next initialization when moduleId changes
7 // 2. When the SpaceModule component is unmounted
8 return () => {
9 console.log(`Module ${moduleId} has been shut down`);
10 };
11 }, [moduleId]);
12
13 return <div className="space-module">Space module: {moduleId}</div>;
14}Unreleased intervals and timeouts can continue running in the background, consuming resources and potentially updating the state of components that no longer exist:
1function Countdown({ seconds, onComplete }) {
2 const [timeLeft, setTimeLeft] = useState(seconds);
3
4 useEffect(() => {
5 // Don't start countdown if already finished
6 if (timeLeft <= 0) {
7 onComplete();
8 return;
9 }
10
11 console.log(`Rozpoczynam odliczanie od ${timeLeft}`);
12
13 // Setting up interval
14 const intervalId = setInterval(() => {
15 setTimeLeft(prevTime => {
16 if (prevTime <= 1) {
17 onComplete();
18 return 0;
19 }
20 return prevTime - 1;
21 });
22 }, 1000);
23
24 // Cleanup function stops the interval
25 return () => {
26 console.log(`Stopping countdown at ${timeLeft}`);
27 clearInterval(intervalId);
28 };
29 }, [timeLeft, onComplete]);
30
31 return <div className="countdown">{timeLeft}</div>;
32}Subscriptions that are not cancelled can continue receiving data and causing state updates:
1function TelemetryMonitor({ shipId }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 let isMounted = true;
6 console.log(`Rozpoczynam monitorowanie statku ${shipId}`);
7
8 // Creating telemetry subscription
9 const subscription = telemetryService.subscribe(shipId, telemetryData => {
10 // Update state only if component is still mounted
11 if (isMounted) {
12 setData(telemetryData);
13 }
14 });
15
16 // Cleanup function cancels the subscription
17 return () => {
18 console.log(`Stopping monitoring of ship ${shipId}`);
19 isMounted = false;
20 subscription.unsubscribe();
21 };
22 }, [shipId]);
23
24 if (!data) return <div>Waiting for telemetry data...</div>;
25
26 return (
27 <div className="telemetry">
28 <h3>Ship telemetry: {shipId}</h3>
29 <p>Oxygen level: {data.oxygen}%</p>
30 <p>Temperature: {data.temperature}°C</p>
31 <p>Velocity: {data.velocity} km/h</p>
32 </div>
33 );
34}Event listeners that are not removed can lead to memory leaks:
1function KeyboardShortcuts({ onEscape }) {
2 useEffect(() => {
3 console.log('Installing keyboard listener');
4
5 // Function handling key press
6 const handleKeyDown = (event) => {
7 if (event.key === 'Escape') {
8 onEscape();
9 }
10 };
11
12 // Adding listener to document
13 document.addEventListener('keydown', handleKeyDown);
14
15 // Cleanup function removes the listener
16 return () => {
17 console.log('Removing keyboard listener');
18 document.removeEventListener('keydown', handleKeyDown);
19 };
20 }, [onEscape]);
21
22 return null; // This component renders nothing visible
23}Connections that remain open can consume resources and cause leaks:
1function MissionControlSocket({ missionId, onMessage }) {
2 const [status, setStatus] = useState('disconnected');
3
4 useEffect(() => {
5 let socket = null;
6
7 // Function initializing connection
8 const connect = () => {
9 console.log(`Connecting to mission ${missionId}`);
10 setStatus('connecting');
11
12 // Creating WebSocket
13 socket = new WebSocket(`wss://mission-control.space/mission/${missionId}`);
14
15 socket.onopen = () => {
16 console.log(`Connected to mission ${missionId}`);
17 setStatus('connected');
18 };
19
20 socket.onmessage = (event) => {
21 const data = JSON.parse(event.data);
22 onMessage(data);
23 };
24
25 socket.onerror = (error) => {
26 console.error(`WebSocket connection error: ${error.message}`);
27 setStatus('error');
28 };
29
30 socket.onclose = () => {
31 console.log(`Disconnected from mission ${missionId}`);
32 setStatus('disconnected');
33 };
34 };
35
36 // Initialize connection
37 connect();
38
39 // Cleanup function closes the connection
40 return () => {
41 console.log(`Closing connection to mission ${missionId}`);
42 if (socket) {
43 socket.close();
44 }
45 };
46 }, [missionId, onMessage]);
47
48 return (
49 <div className="socket-status">
50 Connection status: {status}
51 </div>
52 );
53}Canceling ongoing fetch requests prevents state updates after unmounting:
1function PlanetInfo({ planetId }) {
2 const [planet, setPlanet] = useState(null);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 // Creating controller to cancel request
8 const abortController = new AbortController();
9 const signal = abortController.signal;
10
11 async function fetchPlanetData() {
12 try {
13 setLoading(true);
14
15 console.log(`Data fetching planety ${planetId}`);
16 const response = await fetch(`https://api.space.com/planets/${planetId}`, {
17 signal // pass signal to fetch
18 });
19
20 if (!response.ok) {
21 throw new Error(`HTTP Error: ${response.status}`);
22 }
23
24 const data = await response.json();
25 setPlanet(data);
26 setError(null);
27 } catch (err) {
28 // Ignore cancellation errors
29 if (err.name !== 'AbortError') {
30 console.error(`Error fetching data: ${err.message}`);
31 setError(`Failed to fetch planet data: ${err.message}`);
32 }
33 } finally {
34 setLoading(false);
35 }
36 }
37
38 fetchPlanetData();
39
40 // Cleanup function cancels ongoing requests
41 return () => {
42 console.log(`Canceling request for planet ${planetId}`);
43 abortController.abort();
44 };
45 }, [planetId]);
46
47 if (loading) return <div>Loading planet data...</div>;
48 if (error) return <div>Error: {error}</div>;
49 if (!planet) return <div>No planet data</div>;
50
51 return (
52 <div className="planet-card">
53 <h2>{planet.name}</h2>
54 <p>Typ: {planet.type}</p>
55 <p>Diameter: {planet.diameter} km</p>
56 <p>Distance from Sun: {planet.distanceFromSun} million km</p>
57 </div>
58 );
59}Although using AbortController is the preferred way to cancel fetch requests, in cases where we cannot easily cancel an asynchronous operation, we can use an
isMounted flag:1function AsteroidTracker({ asteroidId }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 let isMounted = true; // Flag indicating whether component is mounted
6
7 async function fetchAsteroidData() {
8 const response = await fetch(`https://api.nasa.gov/neo/rest/v1/neo/${asteroidId}`);
9 const asteroidData = await response.json();
10
11 // Update state only if component is still mounted
12 if (isMounted) {
13 setData(asteroidData);
14 }
15 }
16
17 fetchAsteroidData();
18
19 // Cleanup function changes the flag
20 return () => {
21 isMounted = false;
22 };
23 }, [asteroidId]);
24
25 // Rendering...
26}When synchronizing React state with an external state store, it's important to restore the previous state during cleanup:
1function ThemeSwitcher({ theme }) {
2 useEffect(() => {
3 // Save current theme
4 const previousTheme = document.body.dataset.theme;
5
6 // Set new theme
7 document.body.dataset.theme = theme;
8 console.log(`Changing theme to: ${theme}`);
9
10 // Restore previous theme during cleanup
11 return () => {
12 console.log(`Restoring previous theme: ${previousTheme || 'default'}`);
13 document.body.dataset.theme = previousTheme || 'default';
14 };
15 }, [theme]);
16
17 return (
18 <div className="theme-info">
19 Current theme: {theme}
20 </div>
21 );
22}When multiple components share resources, a reference counting system should be used:
1// Singleton for managing shared resources
2class SharedResourceManager {
3 constructor() {
4 this.resources = new Map();
5 this.refCounts = new Map();
6 }
7
8 // Acquire resource (increment reference count)
9 acquire(resourceId, creator) {
10 if (!this.resources.has(resourceId)) {
11 // Create resource if it doesn't exist
12 this.resources.set(resourceId, creator());
13 this.refCounts.set(resourceId, 0);
14 }
15
16 // Increment reference count
17 this.refCounts.set(resourceId, this.refCounts.get(resourceId) + 1);
18 return this.resources.get(resourceId);
19 }
20
21 // Release resource (decrement reference count)
22 release(resourceId) {
23 if (!this.resources.has(resourceId)) return;
24
25 // Decrement reference count
26 const newCount = this.refCounts.get(resourceId) - 1;
27 this.refCounts.set(resourceId, newCount);
28
29 // Remove resource when no more references
30 if (newCount <= 0) {
31 const resource = this.resources.get(resourceId);
32 if (resource.dispose && typeof resource.dispose === 'function') {
33 resource.dispose();
34 }
35 this.resources.delete(resourceId);
36 this.refCounts.delete(resourceId);
37 }
38 }
39}
40
41// Singleton for use across the entire application
42export const sharedResources = new SharedResourceManager();
43
44// Hook for using shared resource
45function useSharedResource(resourceId, createResource) {
46 useEffect(() => {
47 // Acquire resource
48 const resource = sharedResources.acquire(resourceId, createResource);
49 console.log(`Resource ${resourceId} acquired, number of users: ${sharedResources.refCounts.get(resourceId)}`);
50
51 // Release resource during cleanup
52 return () => {
53 sharedResources.release(resourceId);
54 console.log(`Resource ${resourceId} released`);
55 };
56 }, [resourceId]);
57
58 return sharedResources.resources.get(resourceId);
59}
60
61// Example usage
62function SpaceshipScanner() {
63 // This resource will be shared between all component instances
64 const scanner = useSharedResource('advanced-scanner', () => {
65 console.log('Scanner initialization (expensive operation)');
66 return {
67 scan: (target) => console.log(`Scanning ${target}...`),
68 dispose: () => console.log('Shutting down scanner')
69 };
70 });
71
72 return (
73 <button onClick={() => scanner.scan('Mars')}>
74 Scan Mars
75 </button>
76 );
77}Problem: Improperly stopping a timer can lead to stack overflow:
1// BAD: Cleanup function doesn't stop the timeout properly
2useEffect(() => {
3 function tick() {
4 setCount(count + 1);
5 setTimeout(tick, 1000);
6 }
7
8 tick();
9
10 return () => {
11 // Problem: we don't have a reference to the current timeout ID
12 };
13}, [count]); // 🚨 Dependency causes the effect to re-runSolution: Przechowuj aktualny identyfikator timeout:
1// GOOD: We save the timeout ID so we can cancel it
2useEffect(() => {
3 let timeoutId = null;
4
5 function tick() {
6 setCount(prevCount => prevCount + 1);
7 timeoutId = setTimeout(tick, 1000);
8 }
9
10 timeoutId = setTimeout(tick, 1000);
11
12 return () => {
13 clearTimeout(timeoutId);
14 };
15}, []); // No dependencies - we run only onceProblem: Sometimes cleanup order matters, especially when we have several nested effects:
1// Problematic cleanup order
2useEffect(() => {
3 // Outer effect
4 const resource = initializeResource();
5
6 useEffect(() => {
7 // Inner effect using the resource
8 resource.use();
9
10 return () => {
11 // This cleanup may be called after the resource is released
12 resource.stopUsing();
13 };
14 }, [someCondition]);
15
16 return () => {
17 // This resource may already be in use by the inner effect
18 resource.dispose();
19 };
20}, []);Solution: Use separate, independent effects:
1// Better approach
2useEffect(() => {
3 // Effect initializing resource
4 const resource = initializeResource();
5
6 return () => {
7 // Cleaning up resource
8 resource.dispose();
9 };
10}, []);
11
12useEffect(() => {
13 // Effect using resource (use useRef or context)
14 if (resourceRef.current) {
15 resourceRef.current.use();
16
17 return () => {
18 if (resourceRef.current) {
19 resourceRef.current.stopUsing();
20 }
21 };
22 }
23}, [someCondition]);Problem: During cleanup we may access outdated state:
1function SpaceshipControl({ shipId }) {
2 const [power, setPower] = useState(100);
3
4 useEffect(() => {
5 // Initializing ship control
6 const ship = spaceshipAPI.connect(shipId);
7 ship.setPower(power);
8
9 return () => {
10 // 🚨 Problem: power may be stale during cleanup
11 console.log(`Disconnecting from ship at power: ${power}`);
12 ship.disconnect();
13 };
14 }, [shipId]); // No dependency on power
15
16 return (
17 <div>
18 <p>Engine power: {power}%</p>
19 <button onClick={() => setPower(power - 10)}>Reduce power</button>
20 </div>
21 );
22}Solution: Use references to store current state values:
1function SpaceshipControl({ shipId }) {
2 const [power, setPower] = useState(100);
3 const powerRef = useRef(power);
4
5 // Updating reference on every state change
6 useEffect(() => {
7 powerRef.current = power;
8 }, [power]);
9
10 useEffect(() => {
11 // Initializing ship control
12 const ship = spaceshipAPI.connect(shipId);
13 ship.setPower(power);
14
15 return () => {
16 // ✅ We use the current value from the ref
17 console.log(`Disconnecting from ship at power: ${powerRef.current}%`);
18 ship.disconnect();
19 };
20 }, [shipId, power]);
21
22 return (
23 <div>
24 <p>Engine power: {power}%</p>
25 <button onClick={() => setPower(power - 10)}>Reduce power</button>
26 </div>
27 );
28}To ensure that effects are properly cleaned up, it's worth writing tests that verify this behavior:
1// Example test for a component with effect cleanup
2import { render, act, screen } from '@testing-library/react';
3import { useState } from 'react';
4
5// Simple component with timer for testing
6function TimerComponent() {
7 const [count, setCount] = useState(0);
8
9 useEffect(() => {
10 const interval = setInterval(() => {
11 setCount(c => c + 1);
12 }, 100);
13
14 return () => clearInterval(interval);
15 }, []);
16
17 return <div data-testid="count">{count}</div>;
18}
19
20// Effect cleanup test
21test('effect cleanup stops the interval', async () => {
22 jest.useFakeTimers();
23
24 const { unmount } = render(<TimerComponent />);
25
26 // Allow a few timer ticks
27 act(() => {
28 jest.advanceTimersByTime(250);
29 });
30
31 const countBefore = Number(screen.getByTestId('count').textContent);
32
33 // Unmount component (should clean up effect and stop timer)
34 unmount();
35
36 // Advance clock forward once more
37 act(() => {
38 jest.advanceTimersByTime(250);
39 });
40
41 // Since component is unmounted and timer stopped,
42 // we can only check that counter didn't grow too much before unmounting
43 expect(countBefore).toBeGreaterThanOrEqual(2);
44 expect(countBefore).toBeLessThanOrEqual(3);
45
46 jest.useRealTimers();
47});Always clean up effects that create subscriptions - timers, event listeners, WebSocket connections, etc.
Use AbortController to cancel fetch requests - it's the standard mechanism for canceling asynchronous operations.
Maintain a mounting flag - for asynchronous operations that cannot be cancelled, use an
isMounted flag and check it before updating state.Store references to resources - local variables in an effect cease to exist after cleanup, so use
useRef to store resource references.Avoid overly complex effects - an effect that does too many things will be harder to clean up properly. Consider splitting it into smaller, more focused effects.
Avoid async cleanup functions - cleanup functions should be synchronous so React can guarantee their execution in the proper order.
Test effect cleanup - it's especially important to test unmounting components that create external subscriptions.
Proper effect cleanup not only prevents memory leaks but can also significantly impact application performance:
Lower memory usage - by releasing unused resources
Fewer unnecessary computations - by canceling operations that are no longer needed
Reduced number of re-renders - by avoiding unnecessary state updates
Faster page reloads - by ensuring all resources are properly released
Cleaning up effects is an often overlooked but crucial aspect of working with React. Just as astronauts must take care of their life support systems, developers must take care of cleaning up their side effects.
Well-written cleanup functions:
Remember that every effect that creates a connection, subscription, or reserves resources should also release them during cleanup.