3 Common Mistakes to Avoid When Handling Events in React
In React apps, event listeners or observers perform certain actions when specific events occur. While it's quite easy to create event listeners in React, there are common pitfalls you need to avoid to prevent confusing errors. These mistakes are made most often by beginners, but it's not rare for them to be the reason for one of your debugging sessions as a reasonably experienced developer.
In this article, we'll be exploring some of these common mistakes, and what you should do instead.
1. Accessing state variables without dealing with updates
Take a look at this simple React app. It's essentially a basic stopwatch app, counting up indefinitely from zero.
import { useState, useEffect } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime(time + 1);
}, 1000);
return () => {
window.clearInterval(timer);
}
}, []);
return (
<div>
Seconds: {time}
</div>
);
}
However, when we run this app, the results are not what we'd expect:
This happens because the time state variable being referred to by the setInterval()
callback/closure refers to the stale state that was fresh at the time when the closure was defined.
The closure is only able to access the time
variable in the first render (which had a value of 0
) but can't access the new time
value in subsequent renders. JavaScript closure remembers the variables from the place where it was defined.
The issue is also due to the fact that the setInterval()
closure is defined only once in the component.
The time
variable from the first render will always have a value of 0
, as React doesn't mutate a state variable directly when setState
is called, but instead creates a new variable containing the new state. So when the setInterval
closure is called, it only ever updates the state to 1
.
Here are some ways to avoid this mistake and prevent unexpected problems.
1. Pass function to setState
One way to avoid this error is by passing a callback to the state updater function (setState
) instead of passing a value directly. React will ensure that the callback always receives the most recent state, avoiding the need to access state variables that might contain old state. It will set the state to the value the callback returns.
Here's how we apply this for our example:
import { useState, useEffect } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 👇 Pass callback
setTime((prevTime) => prevTime + 1);
}, 1000);
return () => {
window.clearInterval(timer);
}
}, []);
return (
<div>
Seconds: {time}
</div>
);
}
Now the time
state will be incremented by 1 every time the setInterval()
callback runs, just like it's supposed to.
2. Event listener re-registration
Another solution is to re-register the event listener with a new callback every time the state is changed, so the callback always accesses the fresh state from the enclosing scope.
We do this by passing the state variable to useEffect
's dependencies array:
import { useState, useEffect } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setTime(time + 1);
}, 1000);
return () => {
window.clearInterval(timer);
}
}, [time]);
return (
<div>
Seconds: {time}
</div>
);
}
Every time the time
state is changed, a new callback accessing the fresh state is registered with setInterval()
. setTime()
is called with the latest time
state added to 1, which increments the state value.
2. Registering event handler multiple times
This is a mistake frequently made by developers new to React hooks and functional components. Without a basic understanding of the re-rendering process in React, you might try to register event listeners like this:
import { useState } from 'react';
export default function App() {
const [time, setTime] = useState(0);
setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 1000);
return (
<div>
Seconds: {time}
</div>
);
}
Or you might put it in a useEffect
hook like this:
import { useState } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 1000);
});
return (
<div>
Seconds: {time}
</div>
);
}
If you do have a basic understanding of this, you should be able to already guess what this will lead to on the web page.
What's happening?
What's happening is that in a functional component, code outside hooks, and outside the returned JSX markup is executed every time the component re-renders.
Here's a basic breakdown of what happens in a timeline:
- 1st render: listener 1 registered
- 1 second after listener 1 registration:
time
state updated, causing another re-render) - 2nd render: listener 2 registered.
- Listener 1 never got de-registered after the re-render, so...
- 1 second after last listener 1 call: state updated
- 3rd render: listener 3 registered.
- Listener 2 never got de-registered after the re-render, so...
- 1 second after listener 2 registration: state updated
- 4th render: listener 4 registered.
- 1 second after last listener 1 call: state updated
- 5th render: listener 5 registered.
- 1 second after last listener 2 call: state updated
- 6th render: listener 6 registered.
- Listener 3 never got de-registered after the re-render, so...
- 1 second after listener 3 registration: state updated.
- 7th render: listener 7 registered...
Eventually, things spiral out of control as hundreds and then thousands (and then millions) of callbacks are created, each running at different times within the span of a second, incrementing the time
by 1.
The fix for this is already in the first example in this article - put the event listener in the useEffect
hook, and make sure to pass an empty dependencies array ([]
) as the second argument.
import { useEffect, useState } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 1000);
}, []);
return (
<div>
Seconds: {time}
</div>
);
}
useEffect
runs after the first render and whenever any of the values in its dependencies array change, so passing an empty array makes it run only on the first render.
The time increases steadily now, but as you can see in the demo, it goes up by 2 seconds, instead of 1 second in our very first example. This is because in React 18 strict mode, all components mount, unmount, then mount again. so useEffect
runs twice even with an empty dependencies array, creating two listeners that update the time by 1
every second.
We can fix this issue by turning off strict mode, but we'll see a much better way to do so in the next section.
3. Not unregistering event handler on component unmount.
What happened here was a memory leak. We should have ensured that any created event listener is unregistered when the component unmounts. So when React 18 strict mode does the compulsory unmounting of the component, the first interval listener is unregistered before the second listener is registered when the component mounts again. Only the second listener will be left and the time will be updated correctly every second - by 1
.
You can perform an action when the component unmounts by placing in the function useEffect
optionally returns. So we use clearInterval
to unregister the interval listener there.
import { useEffect, useState } from 'react';
export default function App() {
const [time, setTime] = useState(0);
useEffect(() => {
console.log('here');
const timer = setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 1000);
// 👇 Unregister interval listener
return () => {
clearInterval(timer);
}
}, []);
return (
<div>
Seconds: {time}
</div>
);
}
useEffect
's cleanup function runs after every re-render, not only when the component unmounts. This prevents memory leaks that happen when an observable prop changes value without the observers in the component unsubscribing from the previous observable value.
Conclusion
Creating event listeners in React is pretty straightforward, you just need to be aware of these caveats, so you avoid unexpected errors and frustrating debugging spells. Avoid accessing stale state variables, don't register more event listeners than required, and always unregister the event listener when the component unmounts.
See also
- Create React App alternative: 5 times leaner, 0 vulnerabilities
- When it doesn't work on your machine or your brain or anywhere...
- 3 ways to show line breaks in HTML without ever using br
- The 5 most transformative JavaScript features from ES11
- This new JavaScript operator is an absolute game changer
- Why does [] == ![] return TRUE in JavaScript?