A Complete Beginner's Guide to useEffect Hook [Part 3]

A Complete Beginner's Guide to useEffect Hook [Part 3]

·

9 min read

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 handle componentDidMount, 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 the document.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 from 0 to 1, It will force the App component to re-render, now with the updated value. useEffect will run asynchronously setting the title to the updated value of count that is 1

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.

codepen

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:

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