A Complete Beginner's Guide to useEffect Hook [Part 3]
Introduction
useEffect
is a React Hook that is used to handle side effects in React functional components. Introduced in late October 2018, it provides a single API to handlecomponentDidMount
,componentDidUnmount
,componentDidUpdate
as what was previously done in class-based React components.
What is useEffect Hook?
According to React's official doc :
"The Effect Hook, useEffect, adds the ability to perform side effects from a functional component"
But what are these side effects that we are talking about?
Well, it means we need to do something after the component renders such as data fetching, changes to the DOM, network requests. These kinds of operation are called effects and can be done using the useEffect
hook.
A useEffect
hook takes in two parameters, a callback function and a dependency array respectively.
const callbackFunction = () => { }
dependencyArray = [value1, value2, value3, ...]
useEffect(callbackFunction, dependencyArray)
Or quite simply the above can be summed together and usually what we see in codebases:
useEffect( () => {},
[value1, value2, value3, ...]
)
useEffect in action :
Suppose we have a counter button that increases the count by 1 when clicked :
function App() {
const [count, setCount] = React.useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>click</button>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
What if I want this count value to get dynamically reflected on the page title (i.e. next to the favicon icon), for every button click?
Now, this does sound like we have to handle an effect triggered by the component, hence a perfect use case for the useEffect hook.
Let's import useEffect at the top and call the hook inside the component (just as we did for useState hook). useEffect takes in two arguments, a callback function to trigger and a dependency array, which we will about later in this post :
function App() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
document.title = count;
});
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
Here's how the above React component will behave :
- The
App
functional component will return the HTML and render it to the screen with an initial count of 0, set by the useState hook. - Immediately, the
useEffect
hook runs asynchronously and sets thedocument.title
to the initial count i.e. 0. - The rule of thumb is, whenever something inside the component changes(say, click of a button!), the
App
component will re-render itself with an updated value. - Suppose we click the
increment
button setting the count value from0
to1
, It will force theApp
component to re-render, now with the updated value. useEffect will run asynchronously setting the title to the updated value of count that is1
Adapting to Correct Mental Model :
While the useEffect
hook seems easy to implement when working with isolated demo components, it is highly likely to run into issues when dealing with large codebases.
The reason being, poor understanding of underlying concepts and continuous comparison with class-based React lifecycle methods.
Back in the day, when we were using class-based components (no issues if you haven't!), the component side-effects were handled using Lifecycle Methods, and useEffect hook somewhat does the same thing what
componentDidMount
, componentDidUpdate
and componentWillUnmount
APIs did in Lifecycle methods, but they do differ in how the things get handled. Applying Lifecycle's mental model to hooks could result in unnecessary & unexpected behaviour.
To truly grasp useEffect, we have to "unlearn" the lifecycle way of doing things, as quoted by Dan Abramov,
"It’s only after I stopped looking at the useEffect Hook through the prism of the familiar class lifecycle methods that everything came together for me."
Let's create a class-based component first,
class App extends React.Component {
state = {
name: ""
};
componentDidMount() {
setTimeout(() => {
console.log("MOUNT", this.state.name);
}, 3000);
}
render() {
return (
<div>
<input
value={this.state.name}
onChange={(event) => this.setState({ name: event.target.value })}
/>
</div>
);
}
}
As you can see the console
message fires after 3s, what if in between those 3 seconds, we type something to the <input />
field?
Will the componentDidMount
print empty this.state.name
or would it capture the latest value from the input component?
The answer is, it would capture the latest value, the reason being how lifecycle methods work in a class-based component.
render method creates a DOM node -> componentDidMount is called -> State is updated -> DOM is re-rendered fetching the latest value from state.
Now if we translate the same code to a hook based functional component, it works totally different. The functional component returns a HTML node making initial state value to be empty on the very first mount.
useLayoutEffect
is another hook that can replicate the class-based example more accurately. Kent C Dodds explains very well when to use each in this post
Play around with the code here here
Dependency Array :
The second parameter for useEffect
is a dependency array. It is an array of all the values on which the side-effect should run/ trigger itself.
For example, let's see this counter component, where when a button is clicked the count value increments by 1, with the help of useState
hook.
function App(){
const [count, setCount] = React.useState(0)
React.useEffect(() => {console.log("Running Effect")})
handleChange = () => setCount(prev => prev + 1)
return(
<div>
{console.log("COMPONENT RE-RENDER")}
<h1>Hello</h1>
<button onClick={handleChange}>click</button>
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'))
Now, what can we learn from the above example? As we can notice, there is an useEffect
hook with no second argument. This would result in re-rendering of the App
component whenever a value inside changes, in this case, the count
value is changing. Hence for every button click the component will keep on re-rendering itself, printing COMPONENT RE-RENDER
to the console.
How do we prevent this?
By adding a second argument to the useEffect
hook.
function App(){
const [count, setCount] = React.useState(0)
React.useEffect(() => {console.log("Running Effect")}, [])
handleChange = () => setCount(prev => prev + 1)
return(
<div>
{console.log("COMPONENT RE-RENDER")}
<h1>Hello</h1>
<button onClick={handleChange}>click</button>
</div>
)
}
At the very first mount, we will see both the logs to the console,
Running Effect
COMPONENT RE-RENDER
But this time, as we click on the button there won't be any log from the useEffect
hook as the empty array makes sure to run it just once and all the subsequent logs will be from App
Running Effect
COMPONENT RE-RENDER
COMPONENT RE-RENDER // keep logging as many times as the button clicks
Let's go a step further and try filling in the dependency array list with count
value as :
React.useEffect(() => {console.log("Running Effect")}, [count])
This time things get interesting as it logs both the console text.
Running Effect
COMPONENT RE-RENDER
Running Effect
COMPONENT RE-RENDER
... // keep logging both the text for button clicks
The first text("Running Effect") is rendered as the effect is triggered whenever the array item modifies(count as mentioned there) and it does for button clicks.
while the second text("COMPONENT RE-RENDER") is very much expected as the value inside the component itself is changing, so naturally, it has to re-render to update the DOM with the latest value.
Incorrect Dependency Array :
It is worth mentioning that incorrect use of dependency array items could lead to issues that are harder to debug. React team strongly advises to always fill in items in the array and not to leave them out.
There is a very helpful exhaustive-deps
ESlint rule which helps us in issues such as stale closure
which might be due to incorrect dependency or even several other reasons and helps us auto-fix it.
Read more in-depth about the announcement here
useEffect with cleanup function :
As we have read earlier in this post, useEffect
expects either an undefined or an optional cleanup function as its return value. A Cleanup function can be thought of as a way to clear out the side effects when the component unmounts.
useEffect(() => {
// side effect logic here
})
// cleanup function
return () => {
// logic
}
Let's see cleanup function into action into a very contrived example below:
function App() {
const [number, setNumber] = useState(0);
useEffect(() => {
console.log("number is", number);
return () => {
console.log("running cleanup function");
};
}, [number]);
return (
<div className="App">
<input
type="number"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
<p>{number}</p>
</div>
);
}
A Cleanup function is used in a very small number of use cases such as clearing out timers, cleaning unnecessary event listeners, unsubscribing to a post etc. If not sanitized properly, they could lead to something called a Memory leak in JavaScript.
Batching multiple useEffects :
What's best, putting different side-effect into one useEffect
hook or in multiple?
Honestly, it depends on the use case and how we are interacting with various components. One important thing to note here is that react will apply effect in the order they were written(in case we have multiple useEffect hooks)
It is perfectly fine to do this in a single component :
useEffect(() => {
// Second side effect
})
useEffect(() => {
// First side effect
})
Conceptual Pitfalls to Avoid :
1. useEffect hook does not truly mimic the componentDidMount
lifecycle method. Same goes for componentDidMount
& componentDidUpdate
. While the end result might look similar when implementing, the order in which they are called and mounted is very distinctive as we've already discussed in the above point.
2. The useEffect hook expects us to return a cleanup function, to unmount/clear the side-effects after a certain condition has been fulfilled, if not provided it returns undefined
. We have to make sure not to return anything else when dealing with an async
function, as an asynchronous function returns a promise.
The following code is wrong as it returns an unexpected promise from useEffect Hook
const App = () => {
useEffect(async () => {
const unsubsribe = await subscriberFunction();
return () => {
unsubscribe()
}
}, [])
return <div></div>;
}
Now, there are various ways to deal with an async
function inside a useEffect
hook. we can use IIFE
style technique such as :
const App = () => {
useEffect(() => {
async function subscriberFunction() {
await fetchIds();
}
subscriberFunction();
}, []);
return <div></div>;
};
3. The order in which useEffect has been specified in a component matters while invoking.
Wrapping Up :
React useEffect
hook deviates itself from the class-based lifecycle approach.
It takes time and practice to grasp useEffect's
best patterns and foundational concepts, which when used correctly, can prove to be incredibly powerful for handling side-effects in React Applications.
Some Important Resources that I have collected over time:
- https://overreacted.io/a-complete-guide-to-useeffect/
- https://stackoverflow.com/questions/53253940/make-react-useeffect-hook-not-run-on-initial-render?rq=1
- https://reacttraining.com/blog/useEffect-is-not-the-new-componentDidMount/
Loved this post? Have a suggestion or just want to say hi? Reach out to me on Twitter
Originally written by Abhinav Anshul for Blockchain Works