useEffect - The Hook React Never Should Have Rendered
React was once a fantastic library. Quick to learn, easy to customize and made it easy for you to write clean code. At least it used to be, at the time when they first white-painted the library with hooks instead of the old browny class components.
When React repainted the library, they did a mistake. They left a red brush in the white bucket which they now are using to paint the whole library into a pink disaster. React repainted their future with React 18, but in that picture, the useEffect hook doesn't belong.
I think it's time to get rid of that dangerous and disputed hook, but until then, I'll give you some tips of how to use it without getting pink paint all over your code.
In This Article
- useEffect - The Red Brush in the White Bucket
- React Keeps Painting With the Red Brush
- The Proper Way To Use useEffect
- Importance of Idempotency
- TL;DR
useEffect - The Red Brush in the White Bucket
React have added a wonderful useEffect hook to their bucket of hooks. Only problem, it doesn't belong there. When React 18 first came, the web exploded with warnings about useEffect, claiming it shouldn't be used and that we should not think about React in life cycles.
Truth is, useEffect was an issue long before that. It was very common to see developers leaving out the dependency array when they intended to have an empty dependency array, which causes the useEffect to be effectively useless.
And since React unfortunately leaves a lot of the optimization of the code to the developer, you may need to optimize your code using useCallback, otherwise you can get stuck in infinite rendering loops. Or, in a similar way, it can happen that your code will run on every render.
const ExampleComponent = () => {
// This function will be triggered on every render.
const getData = () => {
// Get some data from server.
}
useEffect(() => {
getData()
}, [getData]);
return <div>Example<div>
}
const ExampleComponent = () => {
// Solution is to optimize the code with a useCallback...
const getData = useCallback(() => {
// Get some data from server.
}, [])
useEffect(() => {
// We could also place the getData function in the useEffect,
// with the drawback of readability if the function is big.
// const getData = () => { ... }
getData()
}, [getData]);
return <div>Example<div>
}
In earlier days, there were also a lot of articles about why you always should add all dependencies to your useEffect, even when you don't want the effect to run when some of the dependencies changes. That lead to ugly hacks with if statements to avoid code from running.
import { useEffect } from 'react'
const Component = ({ someOtherDependency }) => {
const [value, setValue] = useState(null)
useEffect(() => {
// A common seen if statement added just to
// prevent the effect from running.
if (value === null) {
setValue("someValue")
}
}, [value, someOtherDependency])
return <div>Example<div>
}
Other alternatives were more clean, but only usable in a few cases. Below example shows a trick to avoid adding a state variable to the dependency array.
import { useEffect } from 'react'
const Component = () => {
const [count, setCount] = useState(0)
useEffect(() => {
// By passing a function to setCount, we can access its old
// value without adding count to the dependency array.
setCount(oldCount => oldCount + 1)
}, [])
return <div>Example<div>
}
React Keeps Painting With the Red Brush
If it wasn't difficult enough to make use of useEffect in React 17, React 18 made it even more troublesome. The main problem with the hook today isn't ugly if statements, missing dependencies or the fact that you should add all dependencies to the dependency array regardless of if you want to or not. Fact is, even if you do all of that, your useEffect may still run several times, due to concurrent rendering.
Luckily, React creators noticed concurrent mode caused a lot of concerns, so they solved it by only enabling it in parts of your code that uses the new React 18 features. A pretty good solution I would say. Pretty.
Problem is, the React docs for useEffect is quite extensive. When opting in for the new features, and thereby turning on concurrent mode, the docs for useEffect gets even more complicated. One page describing useEffect is not enough, you need another long page, and another one, and another, and another...
You have to think about if concurrent mode is enabled. You have to think of all the pitfalls. You have to think of all the optimizations. You have to think about what is best practice. You have to think about how the component renders even if you are encouraged not to think in terms of the old class life cycle methods such as componentDidMount and componentDidUpdate.
React is no longer a framework that has a low learning code. It's quick to start building with, but it takes a long time to learn how to write it correctly without introducing plenty of bugs.
Visual representation of React. Can you spot the useEffect?
The Proper Way To Use useEffect
React is getting a lot of new good features. Meanwhile, useEffect is becoming more and more dangerous. This article would become way too long if I continued describing issues with useEffect, just look at how big documentation there is for it. For that reason, I will give you one single tip for how to handle useEffect.
Let useEffect render.
With letting it render, I don't mean you voluntarily should let it render on every rendering. What I mean is that you should not be afraid of letting it render too many times. Add all dependencies to its dependency array and let it do its work every time it runs.
What you shouldn't do, is to prevent the effect from running in certain use cases. The below code is awful and extremely bug prone.
const getDataFromBackend = () => {
// Some code.
}
const ExampleComponent = ( {
someVariable,
anotherVariable,
thirdVariable
}) => {
useEffect(() => {
if (someVariable === null) {
if (anotherVariable > 10
&& (thirdVariable !== undefined || thirdVariable !== null)
) {
getDataFromBackend()
}
}
}, [someVariable, anotherVariable, thirdVariable]);
return <div>Example<div>
}
The ugly code above should look like this instead:
const getDataFromBackend = () => {
// Some code.
}
const ExampleComponent = ( {
someVariable,
anotherVariable,
thirdVariable
}) => {
useEffect(() => {
getDataFromBackend()
}, [someVariable, anotherVariable, thirdVariable]);
return <div>Example<div>
}
But? We cannot spam backend with plenty of network requests? Can we?
No, you should not spam backend. What you should do, is to make sure to write getDataFromBackend only fetch data when necessary. Not with the help of if statements, but by using caches, debounce or throttling.
Hooks like useSWR, useQuery and RTK Query handle such things for you, with some need for configurations. Using hooks like those are quite essential nowadays, not only because of issues with useEffect, but also because they include lots of logic you otherwise would have to implement yourself, with retries and state handling.
Importance of Idempotency
Under previous heading we could see how to properly fetch data in a useEffect. I also claimed that you should avoid preventing useEffect from running. In some cases, that might not feel possible. Sometimes we cannot use caches or debounce, that may be the case when sending POST requests to backend.
PUT requests should be fine, because they are idempotent by definition, meaning, it doesn't matter how many times an action is triggered, the result of calling it multiple times is the same as calling it a single time.
Sending POST request to a server are not necessarily idempotent, calling it multiple times may cause a unintended behavior or causing bugs. How can we handle that if useEffect can be triggered multiple times?
Answer is, try to avoid using a useEffect at all. There are multiple ways to do that, but it depends on the situation. If the function isn't dependent on the React component's state, it is possible to lift out the function out of the component.
Another example is when triggering effects on user interactions. In that case, you don't need an effect at all. I often see code similar to the code below.
import { useEffect } from 'react'
import { sendPostRequest } from '/services'
const Component = () => {
const [buttonClicked, setButtonClicked] = useState(false)
// Send a request when the button has been clicked.
useEffect(() => {
if (buttonClicked) {
sendPostRequest()
}
}, [buttonClicked])
return <button onClick={() => setButtonClicked(true))}>Click me</button>
}
The thing is, you don't need that useEffect, not even the useState. The code above should look like the code below.
import { useEffect } from 'react'
import { sendPostRequest } from '/services'
const Component = () => {
// This is what you should do if you really want to send the
// request when the button is clicked.
return <button onClick={() => sendPostRequest())}>Click me</button>
}
There are many other ways to get rid of useEffects, you can google how avoid useEffect. But sometimes you do need the effect, and in that case, you should make sure the code within it is idempotent, and then let the effect run as it wants to.
TL;DR
React is getting more and more difficult to learn and use. Optimizations are left up to developers and it gets more and more easy to write React code in a wrong way. React has very soon a steep learning curve and the useEffect is one of the main reasons to that.
To make the best out of the situation, one should try not to care too much about optimizing useEffects - let useEffect run many times. Then make sure the code within the effect is idempotent. For fetching data, you have really nice hooks like like useSWR, useQuery and RTK Query which helps you cache requests.
If a cache isn't the solution, a debounce or throttle may be. Many times you can even remove the useEffect completely since the code can be rewritten in a better way without it.
One day, we may see a React without the useEffect, and maybe even without useCallback? Or maybe the issues with the useEffect hook will keep on growing? In the end, I would say React is one hook away from being a wonderful framework.