We use cookies to enhance your experience on the site
CodeWorlds

Cleaning Up Effects

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:

  1. Memory leaks - unused resources that are not released
  2. Updating state of unmounted components - leads to warnings and errors
  3. Unnecessary computations - consuming resources on operations that are no longer needed
  4. Conflicting updates - when old and new effects run simultaneously
  5. 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('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 array

When is the cleanup function called?

The cleanup function is invoked in two situations:

  1. Before re-running the effect - when dependencies change
  2. 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-run

Solution: 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 once

2. 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

  1. Always clean up effects that create subscriptions - timers, event listeners, WebSocket connections, etc.

  2. Use AbortController to cancel fetch requests - it's the standard mechanism for canceling asynchronous operations.

  3. Maintain a mounting flag - for asynchronous operations that cannot be cancelled, use an

    isMounted
    flag and check it before updating state.

  4. Store references to resources - local variables in an effect cease to exist after cleanup, so use

    useRef
    to store resource references.

  5. 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.

  6. Avoid async cleanup functions - cleanup functions should be synchronous so React can guarantee their execution in the proper order.

  7. 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:

  1. Lower memory usage - by releasing unused resources

  2. Fewer unnecessary computations - by canceling operations that are no longer needed

  3. Reduced number of re-renders - by avoiding unnecessary state updates

  4. 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.

Go to CodeWorlds