React hooks: why does useEffect need an exhaustive array of dependencies?

非 Y 不嫁゛ 提交于 2021-01-27 05:26:20

问题


I've got the following use case in a React component.

It is a search user input that uses React Autosuggest. Its value is always an ID, so I only have the user ID as a prop. Therefore at first load to show the username value, I need to fetch it at first mount.

EDIT: I don't want to fetch the value again when it changes later, because I already have the value from my suggestions request.

type InputUserProps = {
  userID?: string;
  onChange: (userID: string) => void;
};

// Input User is a controlled input
const InputUser: React.FC<InputUserProps> = (props) => {
  const [username, setUsername] = useState<string | null>(null);

  useEffect(() => {
    if (props.userID && !username) {
      fetchUsername(props.userID).then((username) => setUsername(username));
    }
  }, []);

  async function loadSuggestions(inputValue: string): Suggestion[] {
    return fetchSuggestions(inputValue);
  }

  function selectSuggestion(suggestion: Suggestion): void {
    props.onChange(suggestion.userID); // I don't want to rerun the effect from that change...
    setUsername(suggestion.username); // ...because I set the username here
  }

  return (
    <InputSuggest
      value={props.userID}
      label={username}
      onSuggestionsRequested={loadSuggestions}
      onSuggestionSelected={selectSuggestion}
    />
  );
};

export default InputUser;

(I'm adding a simplified way this component is called)

const App: React.FC<AppProps> = (props) => {
    // simplified, I use react-hook-form in the real code
    const [userID, setUserID] = useState<string?>(any_initial_value_or_null);
    return <InputUser userID={userID} onChange={(newValue)=>setUserID(newValue)} />
};

export default App;

It works, but I have the following warning on my useEffect hook

React Hook useEffect has missing dependencies: 'props.userID' and 'username'. Either include them or remove the dependency array.eslint(react-hooks/exhaustive-deps)

But if I do so, I will run it more than once as the username value is changed by the hook itself! As it works without all the dependencies, I'm wondering:

  • How can I solve my case cleanly?
  • What are those dependencies for and why is it advised to be exhaustive with them?

回答1:


Looks like the userId should indeed be a dependency, since if it changes you want to run your query again.

I think you can drop the check for username and always fetch when, and only when the userId changes:

useEffect(() => {
    if(props.userID) {
        fetchUsername(props.userID)
            .then((username) => setUsername(username))
    }
}, [props.userID])

Generally speaking, you want to list all closure variables in your effect to avoid stale references when the effect is executed.

-- EDIT to address OP questions: Since in your use case you know you only want to perform the action on the initial mount passing an empty dependency array is a valid approach.

Another option is to keep track of the fetched userID, e.g.

const fetchedIds = useRef(new Set())

whenever you fetch username for a new ID you can update the ref:

fetchedIds.current.add(newId)

and in your effect you can test:

if (props.userID && !fetchedIds.current.has(props.userID)) {
   // do the fetch
}



回答2:


What are those depdendencies?

useEffect takes an optional second argument which is an array of dependencies. Dependencies of the useEffect hook tell it to run the effect whenever one if its dependency changes.

If you don't pass an optional second argument to useEffect hook, it will execute every time the component re-renders. Empty dependency array specifies that you want to run the effect only once, after the initial render of the component. In this case, useEffect hook will almost behave like componentDidMount in class components.

why is it advised to be exhaustive on them ?

Effects see props and state from the render they were defined in. So when you use something from the scope of functional component that participates in react's data flow like props or state, inside the callback function of useEffect hook, that callback function function will close over that data and unless new effect is defined with the new values of props and state, your effect will see the stale props and state values.

Following code snippet demonstrates what could go wrong if you lie about the dependencies of useEffect hook.

In the following code snippet, there are two components, App and User. App component has three buttons and maintains the id of the user which is displayed by the User component. User id is passed as a prop from App to User component and User component fetches the user with the id, passed as prop, from the jsonplaceholder API.

Now the problem in the following code snippet is that it doesn't works correctly. Reason is that it lies about the dependency of the useEffect hook. useEffect hook depends on userID prop to fetch the user from the API but as i skipped adding userID as a dependency, useEffect hook doesn't executes every time userID prop changes.

function User({userID}) {
  const [user, setUser] = React.useState(null);
  
  React.useEffect(() => {
    if (userID > 0) {
      fetch(`https://jsonplaceholder.typicode.com/users/${userID}`)
        .then(response => response.json())
        .then(user => setUser(user))
        .catch(error => console.log(error.message));
    }
  }, []); 
  
  return (
    <div>
      {user ? <h1>{ user.name }</h1> : <p>No user to show</p>}
    </div>
  );
}

function App() {
  const [userID, setUserID] = React.useState(0);
  
  const handleClick = (id) => {
    setUserID(id);
  };
  
  return(
    <div>
      <button onClick={() => handleClick(1)}>User with ID: 1</button>
      <button onClick={() => handleClick(2)}>User with ID: 2</button>
      <button onClick={() => handleClick(3)}>User with ID: 3</button>
      <p>Current User ID: {userID}</p>
      <hr/>
      <User userID={userID} />
    </div>
  );
}

ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Above code snippet shows one of the few problems that could arise if you lie about the dependencies and this is why you must not skip or lie about the dependencies of the useEffect hook or any other hook that has a dependency array, for example useCallback hook.'

To fix the previous code snippet, you just have to add userID as a dependency to the dependency array of useEffect hook so that if executes whenever userID prop changes and new user is fetched with the id equal to userID prop.

function User({userID}) {
  const [user, setUser] = React.useState(null);
  
  React.useEffect(() => {
    if (userID > 0) {
      fetch(`https://jsonplaceholder.typicode.com/users/${userID}`)
        .then(response => response.json())
        .then(user => setUser(user))
        .catch(error => console.log(error.message));
    }
  }, [userID]); 
  
  return (
    <div>
      {user ? <h1>{ user.name }</h1> : <p>No user to show</p>}
    </div>
  );
}

function App() {
  const [userID, setUserID] = React.useState(0);
  
  const handleClick = (id) => {
    setUserID(id);
  };
  
  return(
    <div>
      <button onClick={() => handleClick(1)}>User with ID: 1</button>
      <button onClick={() => handleClick(2)}>User with ID: 2</button>
      <button onClick={() => handleClick(3)}>User with ID: 3</button>
      <p>Current User ID: {userID}</p>
      <hr/>
      <User userID={userID} />
    </div>
  );
}

ReactDOM.render(<App/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

In your case, if you skip adding props.userID in the dependency array of useEffect hook, your effect will not fetch the new data when the prop userID changes.

To learn more about the negative impacts of omitting the dependencies of useEffect hook, see:

  • Don’t Lie to React About Dependencies

How can I solve my case cleanly ?

Since your effect depends on the prop value userID, you should include it in the dependency array to always fetch the new data whenever the userID changes.

Adding props.userID as a dependency to useEffect hook will trigger the effect every time props.userID changes but the problem is that you are unnecessarily using username inside the useEffect. You should remove it because that is not needed since username value doesn't and shouldn't decide when new user data should be fetched. You just want the effect to run whenever props.userID changes.

You could also decouple the action from the state update by using the useReducer hook to manage and update the state.

Edit

Since you only want to run the effect even when userID is used by useEffect hook, in your case it is ok to have an empty array as the second argument and ignore the eslint warning. You could also not omit any dependencies of the useEffect hook and use some condition in useEffect hook that evaluates to false after the effect runs for the first time and updates the state.

Personally i would suggest you to try to change how your components are structured so that you don't have to deal with this kind of problem in the first place.




回答3:


If you are sure that before mounting the InputUser component, all dependent props have been filled and they have the right value, then add eslint-disable-next-line react-hooks/exhaustive-deps right before }, []) line, but If depended props have no value at first component mounting, So you have to add them in useEffect dependencies.



来源:https://stackoverflow.com/questions/63216143/react-hooks-why-does-useeffect-need-an-exhaustive-array-of-dependencies

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!