JavaScript and React course · Module 5: useEffect and Custom Hooks
Cleaning Up Effects
In this lesson10
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.
Why is cleaning up effects important?
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:
- Memory leaks - unused resources that are not released
- Updating state of unmounted components - leads to warnings and errors
- Unnecessary computations - consuming resources on operations that are no longer needed
- Conflicting updates - when old and new effects run simultaneously
- Ghost subscriptions - old subscriptions still responding to events
Basic cleanup function syntax
In useEffect, the cleanup function is returned from the effect function:
1useEffect(() => {
2 // Effect code - runs after rendering
3 console.log('Effect triggered');
4
5 // Cleanup function - runs before the next effect or on unmount
6 return () => {
7 console.log('Effect cleaned up');
8 };
9}, [dependency]); // Dependency arrayWhen is the cleanup function called?
The cleanup function is invoked in two situations:
- Before re-running the effect - when dependencies change
- Before the component unmounts - when the component is removed from the DOM
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}Common cases requiring cleanup
1. Intervals and timeouts
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}2. Subscriptions to external data sources
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}3. DOM event listeners
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}4. WebSockets and real-time connections
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}5. Canceling fetch requests
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}Advanced effect cleanup patterns
1. The isMounted flag
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}2. Synchronization with external state stores
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}3. Handling shared resources
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}Common problems and solutions
1. Stack overflow when cleaning up timers
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 once2. Problems with cleanup ordering
Problem: 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]);3. State inconsistency during cleanup
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}Testing effect cleanup
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});Best practices for cleaning up effects
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
isMountedflag and check it before updating state.Store references to resources - local variables in an effect cease to exist after cleanup, so use
useRefto 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.
Effect Cleanup and Performance
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
Summary
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:
- Prevent memory leaks
- Avoid updating state of unmounted components
- Release system resources
- Ensure application consistency
Remember that every effect that creates a connection, subscription, or reserves resources should also release them during cleanup.
Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Demonstrating cleanup with timers
4function TimerWithCleanup() {
5 const [count, setCount] = useState(0);
6 const [isRunning, setIsRunning] = useState(false);
7
8 useEffect(() => {
9 if (!isRunning) return;
10
11 console.log('⏰ Timer started');
12 const interval = setInterval(() => {
13 setCount(c => c + 1);
14 }, 1000);
15
16 // CLEANUP - very important!
17 return () => {
18 console.log('Timer stopped (cleanup)');
19 clearInterval(interval);
20 };
21 }, [isRunning]);
22
23 return (
24 <div className="demo-card">
25 <h2>⏱Timer with Cleanup</h2>
26 <div className="counter">{count}s</div>
27 <div className="controls">
28 <button onClick={() => setIsRunning(true)} disabled={isRunning}>
29 ▶Start
30 </button>
31 <button onClick={() => setIsRunning(false)} disabled={!isRunning}>
32 ⏹Stop
33 </button>
34 <button onClick={() => { setCount(0); setIsRunning(false); }}>
35 Reset
36 </button>
37 </div>
38 <p className="hint">Open the console to see cleanup logs</p>
39 </div>
40 );
41}
42
43// Demonstrating cleanup with event listeners
44function MouseTrackerWithCleanup() {
45 const [position, setPosition] = useState({ x: 0, y: 0 });
46 const [tracking, setTracking] = useState(false);
47
48 useEffect(() => {
49 if (!tracking) return;
50
51 const handleMouseMove = (e) => {
52 setPosition({ x: e.clientX, y: e.clientY });
53 };
54
55 console.log('Mouse tracking enabled');
56 window.addEventListener('mousemove', handleMouseMove);
57
58 // CLEANUP - removing the event listener
59 return () => {
60 console.log('Mouse tracking disabled (cleanup)');
61 window.removeEventListener('mousemove', handleMouseMove);
62 };
63 }, [tracking]);
64
65 return (
66 <div className="demo-card">
67 <h2>Mouse Tracking</h2>
68 <div className="position">
69 X: {position.x} | Y: {position.y}
70 </div>
71 <button onClick={() => setTracking(!tracking)}>
72 {tracking ? 'Disable tracking' : 'Enable tracking'}
73 </button>
74 <p className="hint">
75 {tracking ? 'Move your mouse!' : 'Click to enable'}
76 </p>
77 </div>
78 );
79}
80
81// Demonstrating cleanup with subscriptions
82function SubscriptionWithCleanup() {
83 const [messages, setMessages] = useState([]);
84 const [subscribed, setSubscribed] = useState(false);
85
86 useEffect(() => {
87 if (!subscribed) return;
88
89 console.log('Subscribed to space channel');
90
91 // Simulating a subscription (e.g. WebSocket)
92 const interval = setInterval(() => {
93 const newMessage = {
94 id: Date.now(),
95 text: `Transmission #${Math.floor(Math.random() * 1000)}`,
96 time: new Date().toLocaleTimeString()
97 };
98 setMessages(prev => [...prev.slice(-4), newMessage]);
99 }, 2000);
100
101 // CLEANUP - canceling the subscription
102 return () => {
103 console.log('Unsubscribed from space channel (cleanup)');
104 clearInterval(interval);
105 };
106 }, [subscribed]);
107
108 return (
109 <div className="demo-card">
110 <h2>Subscription</h2>
111 <div className="messages">
112 {messages.length === 0 ? (
113 <p className="empty">No messages</p>
114 ) : (
115 messages.map(msg => (
116 <div key={msg.id} className="message">
117 [{msg.time}] {msg.text}
118 </div>
119 ))
120 )}
121 </div>
122 <button onClick={() => {
123 setSubscribed(!subscribed);
124 if (subscribed) setMessages([]);
125 }}>
126 {subscribed ? 'Cancel subscription' : 'Subscribe'}
127 </button>
128 </div>
129 );
130}
131
132// Component showing a memory leak problem
133function MemoryLeakDemo() {
134 const [showChild, setShowChild] = useState(true);
135 const [logs, setLogs] = useState([]);
136
137 const addLog = (msg) => setLogs(prev => [...prev.slice(-5), msg]);
138
139 return (
140 <div className="demo-card wide">
141 <h2>Memory Leak Demo</h2>
142 <button onClick={() => {
143 setShowChild(!showChild);
144 addLog(showChild ? 'Component unmounted' : 'Component mounted');
145 }}>
146 {showChild ? 'Unmount component' : 'Mount component'}
147 </button>
148
149 <div className="child-container">
150 {showChild && <ChildWithCleanup onLog={addLog} />}
151 </div>
152
153 <div className="logs">
154 <h3>Logs:</h3>
155 {logs.map((log, i) => <div key={i}>{log}</div>)}
156 </div>
157 </div>
158 );
159}
160
161function ChildWithCleanup({ onLog }) {
162 useEffect(() => {
163 onLog('Effect started');
164
165 const timer = setInterval(() => {
166 onLog('⏰ Timer tick');
167 }, 2000);
168
169 return () => {
170 onLog('Cleanup performed - timer cleared');
171 clearInterval(timer);
172 };
173 }, []);
174
175 return <div className="child">Child component (with effect)</div>;
176}
177
178function App() {
179 return (
180 <div className="app">
181 <h1>Cleaning Up Effects (Cleanup)</h1>
182 <p className="subtitle">Managing resources in useEffect</p>
183 <div className="demos">
184 <TimerWithCleanup />
185 <MouseTrackerWithCleanup />
186 <SubscriptionWithCleanup />
187 <MemoryLeakDemo />
188 </div>
189 </div>
190 );
191}
192
193export default App;Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the best way to organize multiple effects in a component?
2. How do you properly handle errors in asynchronous operations within useEffect?
Hands-on tasks in the game
- Horizontal ordering
Arrange the cleanup syntax elements in useEffect from left to right:
- Code editor
LocalStorage Settings Sync
- Click in order
Arrange the useEffect syntax with a cleanup function:
- Vertical ordering
Order advanced useEffect patterns from basic to complex:
- Code editor
Intersection Observer for Lazy Loading