Search code examples
reactjsreact-hooksuse-effectreact-custom-hooks

Better Structure for React Custom Hooks that avoid `exhaustive-deps` warning?


I noticed that react’s exhaustive-deps line rule doesn’t always play nice with the setState function or when you abstract out customHooks.

For example, if I have a customHook like:

function useValidation(initialThings) {
  const [needsValidation, setNeedsValidation] = useState(false);
  const [thingsToValidate, setThingsToValidate] = useState<Things[]>(initialThings);

  useEffect(() => {
    debounceValidateThings(thingsToValidate);
  }, [things, debounceValidateThings]);

  return {
    needsValidation,
    setNeedsValidation,
    thingsToValidate,
    setThingsToValidate,
  }
}

and I use it one of the setState functions outside of the hook:

 const validationHook = useValidation(initialThings)


 useEffect(() => {
    // Add something to validate
    validationHook.setThingsToValidate(newThings)
    validationHook.setNeedsValidation(true)
  }, [newThings]);

I noticed this returns a warning with exhaustive-deps. It suggest that I add the entire useValidation to the depth which might causes excessive rerenders.

At the same time, it won't let me just add the setState calls: e.g. setThingsToValidate or setNeedsValidation

It still recommends that:

  1. setState be added as a dep even though it doesn’t have to be (https://reactjs.org/docs/hooks-reference.html#usestate)
  2. it recommends the entire useX hook is added to deps instead of just useX.setState which causes unnecessary rerenders

Is there a way around this that doesn’t involve a lint warning? Or this there a paradigm here for abstracting out hooks that I’m missing??


Solution

  • This here:

      return {
        needsValidation,
        setNeedsValidation,
        thingsToValidate,
        setThingsToValidate,
      }
    

    Creates a new object every time your hook runs. Then when you use that object as a hook dependency:

     useEffect(() => {
        // Add something to validate
        validationHook.setThingsToValidate(newThings)
        validationHook.setNeedsValidation(true)
      }, [newThings, validationHook]);
    

    The hook is invalidated every render. It's absolutely correct to list this object as a dependency, because it is a dependency.

    So you have a few choices.


    You could memoize the returned object:

    return useMemo(() => ({
      needsValidation,
      setNeedsValidation,
      thingsToValidate,
      setThingsToValidate,
    }, [
      needsValidation,
      thingsToValidate,
      // eslint should know state setters are stable in this scope
      // so they can be omitted
    ])
    

    And now the identity of the returned object is preserved unless state changes. This makes it safe to use an effect dependency.


    But I think the better approach would be deconstruct the returned object so you just used the property values. The state setters should be stable, and anything that depends on the state values needs to be updated anyway.

    const {
      needsValidation,
      setNeedsValidation,
      thingsToValidate,
      setThingsToValidate,
    } = useValidation(initialThings)
    
    useEffect(() => {
      setThingsToValidate(newThings)
      setNeedsValidation(true)
    }, [newThings, setThingsToValidate, setNeedsValidation]);
    

    Or you could list properties of the returned object as hook dependencies:

    const validationHook = useValidation(initialThings)
    
    useEffect(() => {
      // Add something to validate
      validationHook.setThingsToValidate(newThings)
      validationHook.setNeedsValidation(true)
    }, [
      newThings,
      validationHook.setThingsToValidate,
      validationHook.setNeedsValidation,
    ]);