How to use React's useEffect and useRef hooks?
What are useEffect and what are they used for?
A useEffect in React is a function known as a hook. Hooks start with the prefix "use" (it's a naming convention). The useEffect allows you to manage the lifecycle state of a component and simultaneously execute actions (side effects) reactively.
The useEffect executes asynchronously. It allows you to control a component's behavior during its mounting and unmounting phases.
Understanding the Lifecycle
Let's go through a simple analogy: the sun. It rises, evolves, and then sets. That's the lifecycle of the sun. Then it starts again the next day, and so on.
For a React component, it's exactly the same thing.
Just like the sun, a component's lifecycle consists of three phases:
- Phase 1: Mounting
- Phase 2: Updating
- Phase 3: Unmounting
For each of these phases, we have two distinct steps. The Render. Except for the unmounting phase, which doesn't have a render. Then, step 2, which depending on its phase, will have a different name.
For phase 1, it will be named ComponentDidMount (it has FINISHED rising).
For phase 2, it will be named ComponentDidUpdate (it has FINISHED evolving).
And finally, for phase 3, this step 2 will be named ComponentWillUnmount (it will GO to set).
The useEffect will therefore be used to execute code that will intervene only in one of these three phases: either on mounting, updating, or unmounting.
What does it look like?
useEffect accepts two arguments. The first argument is a callback function which is mandatory, and the second, optional, is the dependency array.
useEffect(() => {
// Here is the code, the "effect" that will be executed ==> MOUNT
return () => {
// the cleanup effect ==> UNMOUNT
}
},
[] // the dependency array.
// Allows the effect to be updated if needed
)
Understanding the Dependency Array Parameter
This parameter will influence when the useEffect is executed.
1.The parameter is not present.
useEffect will execute on every render of the application.
useEffect(() => {
console.log("I execute on every render")
})
2. The parameter is an empty array
useEffect will execute only once on the first render of the page (on component mounting). Its cleanup function on unmounting.
useEffect(() => {
console.log("I execute only once (Mount)")
return () => {
console.log("I execute only on unmount (Unmount)")
}
}, [])
3. The dependency array contains references to the values and/or functions that will be used in the useEffect code.
In the case of a variable (useState), useEffect will execute every time a value present in this dependency array is modified. useEffect is executed only after the component is re-rendered in the DOM (if it's a value from a useState).
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`I execute every time the value of Count changes = ${count}`)
return () => {
console.log("I execute before each re-execution, cleaning up the previous execution = ${count}")
}
}, [count])
What is the purpose of useEffect?
The primary purpose of useEffect is to synchronize data with one or more elements. It's a kind of "event listener" that executes an action at a specific moment.
For example, we want the title of our page to be updated based on a totalPrice value.
const [totalPrice, setTotalPrice] = useState(0);
useEffect(() => {
document.title = `Total of your purchases ${totalPrice} €`
}, [totalPrice])
Here we want our "title" to be synchronized with the value of the "totalPrice" variable. By using useEffect, we avoid any undesired side effects.
Here's a second simple example, where we track the scroll of the page:
useEffect(() => {
const onScroll = () => {
console.log('SCROLL');
};
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
};
}, []);
As we can see, we must not forget to remove the "eventListener" in the cleanup function of our useEffect.
What not to do
A bad practice with useEffect is to modify a state based on another state, where the change in value is tracked by another useEffect. The first side effect is that this will generate unnecessary renders. And above all, it's not synchronization per se.
Bad example:
import { useState, useEffect } from 'react';
const Test = () => {
const [valueA, setValueA] = useState(false)
const [valueB, setValueB] = useState(false)
const [valueC, setValueC] = useState(false)
const handleValueAClick = () => {
setValueA(true)
}
const handleToggleValueB = () => {
setValueB(currentValue => !currentValue)
}
useEffect(() => {
if (valueA && valueB && valueC) {
setValueC(false)
return
}
setValueB(true)
}, [valueA, valueB, valueC])
}
Here, as we can see, based on states, we modify other states. useEffect is not meant to modify values (state) based on conditions.
Instead, you should use this logic in separate functions to modify your states.
const updateValuesIfNeeded = () => {
if (valueA && valueB && valueC) {
setValueC(false)
return
}
setValueB(true)
}
const handleValueAClick = () => {
setValueA(true)
updateValuesIfNeeded()
}
const handleToggleValueB = () => {
setValueB(currentValue => !currentValue)
updateValuesIfNeeded()
}
By doing this, we ensure that we do not trigger unnecessary re-renders and protect ourselves from the appearance of future bugs.
If you need to manage complex scenarios, you can use third-party state management libraries like XState to help you.
Let's go a little further in using useEffect with two scenarios: using setInterval and fetch.
How to use setInterval with useEffect?
We will create a basic counter and show you how to avoid a known bug.
Setup:
import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
Counter value: {count}
</div>
)
}
Natively, in our component, we could simply add the setInterval and it would work.
import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0)
setInterval(() => {
setCount(count + 1)
}, 1000)
return (
<div>
Counter value: {count}
</div>
)
}
However, a visual bug will appear very quickly.
The problem here is that every time the state changes, our "Counter" component will re-run and a new setInterval will be set up. We will therefore have multiple setIntervals attempting to update the state at the same time. While in principle, we need to have ONLY ONE setInterval.
To solve this first problem, let's implement useEffect. We should also make sure to add a clearInterval in the cleanup function of our useEffect.
import { useState, useEffect } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
const intervalID = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => {
clearInterval(intervalID)
}
}, [])
return (
<div>
Counter value: {count}
</div>
)
}
Oops! It doesn't work. Why?
The issue is a problem with closure. Indeed, the callback function passed to setInterval has its own lexical environment, meaning the count variable has been stored in this environment, and it's this "saved" value that is constantly being used.
This means we cannot increment the value of our state. We need to find a way to access the current count value of our state.
Instead of passing the expression to setCount to modify our state as usual, which works in over 95% of cases, we will pass a callback function. This function takes the current state value as a parameter. Thus, we are no longer directly dependent on the count variable of our state.
import { useState, useEffect } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
const intervalID = setInterval(() => {
setCount((currentStateValue) => {
return currentStateValue + 1
})
}, 1000)
return () => {
clearInterval(intervalID)
}
}, [])
return (
<div>
Counter value: {count}
</div>
)
}
This callback can be useful in two cases. Here, as we just saw with setInterval or similar. And in cases where we want to modify the state multiple times in a single execution.
How to use Fetch with useEffect?
You are probably used to doing something like this to load data from an API.
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(data => setState(data))
}, [])
The problem is that this can cause an unexpected error. One of the solutions you often see is to use a flag as a safeguard.
useEffect(() => {
let isCancelled = false
fetch(url)
.then((response) => response.json())
.then((data) => {
if (!isCancelled) {
setState(data))
}
});
return () => {
isCancelled = true;
}
}, [])
Besides not being very elegant, the problem with this solution is that we are not synchronizing anything, even if everything works.
Indeed, if we launch this useEffect multiple times, the fetch will always execute. Our safeguard "isCancelled" just allows us to properly retrieve the data from the last Fetch without having an unwanted error.
The best method for now to counter this problem is to use a "signal" with Fetch.
useEffect(() => {
const abortController = new AbortController();
const signal = abortController.signal;
fetch(url, { signal })
.then((response) => response.json())
.then((data) => setState(data))
.catch((err) => {
if (err.name !== "AbortError") {
// error handling
}
});
return () => {
abortController.abort()
}
}, [])
In this way, if the request is interrupted, we ensure that the Fetch instance is properly destroyed. In the "catch" we intercept all other errors except "AbortError" errors which come from the AbortController (our signal) indicating that the request was aborted.
To avoid using a useEffect for fetching your data, you can use a third-party library, like the excellent TanStack Query or SWR.
Currently, there is no native solution to "fetch" data outside of a useEffect.
BONUS: useEffect vs useLayoutEffect
You might not know this, but there is a hook similar to useEffect. The useLayoutEffect. It has the same signature, it's used in the same way, but there are some essential differences.
What are the differences?
useEffect is triggered asynchronously so as not to block the browser's rendering process, and it executes after the browser has finished painting the screen. This means there can be a delay and another render before the changes are visible to the user.
useLayoutEffect, on the other hand, runs its callback synchronously and executes after the component has rendered but before the browser has painted the screen. It was designed to handle side effects that require immediate DOM updates.
Note: useLayoutEffect is triggered before useEffect.
useLayoutEffect will primarily be used for DOM manipulations. It is useful if we need to perform "measurements" from the DOM. Or, for example, to get the scroll position, to modify styles, for animations or transitions of elements.
Thanks to useLayoutEffect, we can be sure that our modifications will be set before they are displayed on the screen.
This means that when a DOM mutation needs to be visible to the user, it must be triggered synchronously before the final render. This prevents the user from seeing a visual inconsistency (flickering).
useLayoutEffect is therefore a good choice for DOM manipulation tasks that need to occur quickly.
Another special use case is if we update a value (from a useRef, for example) and want to ensure that this value is up to date before executing any other code.
WARNING!
It is important to note that useLayoutEffect can potentially prevent the browser from painting and harm performance. Therefore, it is crucial to be extremely judicious in choosing when to use it. It is preferable to avoid using it when possible. In 99% of cases, you should prefer useEffect.
For Next.js users:
If you encounter server-side rendering errors with useLayoutEffect, you may need to replace it with useEffect.
How to display an animation with useLayoutEffect and useRef?
Here is a very minimalist example using useRef and useLayoutEffect to create transitions and animations.
First, here's the CSS:
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.App {
display: grid;
place-items: center;
width: 100dvw;
min-height: 100dvh;
background-color: #131416;
}
.colorBox {
display: grid;
place-items: center;
background-color: #f1f1f1;
border: 2px solid #333333;
min-width: 60%;
min-height: 60%;
border-radius: 15px;
transition: transform 1.5s ease-in-out,
background-color 1s linear;
}
.colorBox label {
display: flex;
flex-direction: column;
}
.colorBox > label input[type="color"] {
margin-top: 8px;
width: 100%;
}
Below is what our component might look like:
import { useState, useRef, useLayoutEffect } from "react";
import "./styles.css";
export default function App() {
const [colorValue, setColorValue] = useState("#f1f1f1");
const zoomInRef = useRef(true)
const colorBoxRef = useRef(null);
const handleColorChange = (event) => {
setColorValue(event.currentTarget.value);
zoomInRef.current = !zoomInRef.current;
}
useLayoutEffect(() => {
const scaleRatio = zoomInRef.current ? 1.3 : 1;
colorBoxRef.current.style = `background-color: ${colorValue}; transform: scale(${scaleRatio});`;
}, [colorValue]);
return (
<div className="App">
<div ref={colorBoxRef} className="colorBox">
<label>
Choose a color
<input type="color" defaultValue="#f1f1f1" onChange={handleColorChange}/>
</label>
</div>
</div>
);
}
In Conclusion
Now, useEffect, useLayoutEffect, and useRef have no secrets for you. You can now fully leverage their potential and use them confidently in the best possible way in your projects.
If you want to learn more about React, join our React training now.