Well, I am recently making my personal portfolio to re-challenge the job market, and I had no choice but to choose React to make my portfolio's frontend part(Sorry, I am not a FE engineer so I know almost nothing about those "hot" ones like Next.js or Remix). Right, I am making my portfolio with "Vanilla" React which is not common nowadays.
However, while building logics of fetching data from the backend using useEffect, I heard a weird hissing sound from my old friend Intel Macbook Pro.
Wow, I must have done something wrong. But from where does this horrible bug come out? After a bit long thought, I finally could figure out that the reason was useEffect
.
Observation
Let me explain this with a simple code.
First of all, here is a simple React component fetching data from an external API. Here we'll use JSON Placeholder for simplicity.
(I prefer TypeScript but the pure JS version would have no difference in its logic)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
interface User {
userId: number,
id: number,
title: string,
completed: boolean
}
const API_URL = 'https://jsonplaceholder.typicode.com/todos/1';
export function App() {
const [userInfo, setUserInfo] = useState<User | null>(null);
useEffect(() => {
const fetchUserInfo = async () => {
try {
const response = await axios.get<User>(API_URL);
setUserInfo(response.data);
} catch(error) {
if (error instanceof Error) {
console.error(error.message);
}
}
};
fetchUserInfo();
}, [userInfo])
if (!userInfo) {
return <h1>Loading...</h1>
}
return (
<div>
user Id: {userInfo.userId} title: {userInfo.title} completed: {userInfo.completed}
</div>
);
}
Okay, this is a very simple application rendering a piece of data we fetched from an external API, and it works fine. So what is the matter?
Let's look into the useEffect
part, which is our topic of interest. To see what's happening when we fetch the data, we simply add console.log
within the fetchUserInfo
function
useEffect(() => {
const fetchUserInfo = async () => {
console.log("here is where our bug could show up");
try {
const response = await axios.get<User>(API_URL);
setUserInfo(response.data);
} catch(error) {
if (error instanceof Error) {
console.error(error.message);
}
}
};
fetchUserInfo();
}, [userInfo]) // => notice that we have our dependency array filled with "userInfo" state managed by useState
Now save the code and see what's happening on the console tap of Chrome:
A-ha. Now we see the problem. So our useEffect
keeps running its enrolled callback over and over again. Why is this bug happening?
The Source of the Bug?
If you read official docs on useEffect, you'll find that a callback enrolled into useEffect
is invoked in the following mechanism(in simple words):
- after the component renders
- if an element in the dependency array(the second argument of
useEffect
) changes - if it is empty([]
), there is no element to be referred by React, so React skipsuseEffect
Hence our senario of component rendering is like this:
-
App
component renders. -
useEffect
invokesfetchUserInfo
, which updatesuserInfo
state. - Since the component's state has changed,
App
will re-render. - Now React looks up the dependency array passed to
useEffect
to determine whether it should re-invokefetchUserInfo
or skip it. However, since we have our dependency array as[userInfo]
, anduserInfo
has changed fromnull
to an object,fetchUserInfo
gets re-invoked. -
userInfo
state is "updated" again. - So like in step 3,
App
re-renders again - Now the actual game begins: at step 5, we have our
userInfo
changed fromnull
to a JS object. However, here we have two identical objects. So in our intention the component should not be re-rendered. However, since React compares the elements in the dependency array withObject.is
method, React recognizes those two identical objects as different objects and determines that the component should re-render. - We then basically go back to step 6 and repeat the process recursively.
Practical Suggestions
Now everything is clear. Although the server data doesn't change at all, React finds fetched data to be different from the previous one because of its comparison mechanism used for the dependency array of useEffect
. But then, how do we solve our problem?
My personal suggestions on this problem are the followings:
If you don't need to dynamically change the value of the components' state(like pagination in my case), then don't be bothered to deal with it. Leave the dependency array empty no matter what your linter alerts.
If you have no choice but to get along with the state, make sure you provide the dependency array with JS's primitive data types. As we have mentioned above,
useEffect
compares the current data with the previous data in the dependency array withObject.is
method. So even if newly fetched data has exactly the same content as the previous state, it is recognized as a new object byuseEffect
so the state gets updated, making another re-rendering of the component.
So in our example, the useEffect
should be like
useEffect(() => {
const fetchUserInfo = async () => {
console.log("No bugs anymore");
try {
const response = await axios.get<User>(API_URL);
setUserInfo(response.data);
} catch(error) {
if (error instanceof Error) {
console.error(error.message);
}
}
};
fetchUserInfo();
}, [userInfo.userId]) // => notice that "userInfo" has been replaced with "userInfo.userId", which is the number type in JS.
That's it! I don't know if the contemporary trend is using useEffect
directly or not, but I hope this article could be some help for someone out there. If there is something wrong in the article, please let me know.
Happy hacking:)
Top comments (0)