DEV Community

Cover image for The exhaustive-deps rule has to be treated seriously
wkrueger
wkrueger

Posted on • Edited on

The exhaustive-deps rule has to be treated seriously

(caption: Mrs. Tedesco writing a Todo Application in React with hooks)

It happens when we write useEffect hooks. We intend to just run some code when X changes, but then ESLint tells us to add Y and Z to the dependency list.

useEffect(() => {
  setCount(count + 1)
  // eslint-disable-next-line
}, [])
Enter fullscreen mode Exit fullscreen mode

Ignoring this rule is very bad. It opens up our code to a class of weird bugs (ex: count gets a value from the past). But most importantly, it hides bad design in other parts of the component.

I can strongly claim that EVERY useEffect can be made compliant with the ESLint rule while still mantaining the desired behavior. The solutions might not be straightforward, but it is always better to change other parts of the code than to add the rule. The overal benefit will be more consistent code.

Reviewing what useEffect is for

useEffect is mostly about updating derived state.

  • We have C that depends on A and B.
  • When either A or B changes, update C.
  • This update may be asynchronous.
function Page({ id, mode }: { id: number; mode: 'read' | 'edit' }) {
  const [formData, setFormData] = useState<null|FormData>(null)
  const handleError = useErrorHandler()
  useEffect(() => {
    loadFormContents(id, mode)
      .then(setFormData)
      .catch(handleError)
  }, [id, mode])

  if (!formData) return null;
  return <TheForm formData={formData} />
}
Enter fullscreen mode Exit fullscreen mode

Sometimes we may not straight notice the existence of derived state. The dependency array and the ESLint rule are there to help us. In the example above, the form contents depend on id. What if the page route changes, bringing in a new id? We need to handle the prop change.

useEffect can also happen with an empty dependency array, which showcases that we also need it for async behavior, even when there's no derived state.

Identifying stable references

The ESLint plugin is not able to define every variable's lifecycle. It does the basic work of checking if the variable is defined inside of the component (it is not a constant) and if it is one of the known React stable variables.

If you know that a variable is stable (it won't change between renders), you can just safely keep it in the dependency array knowing that it will never trigger an effect.

Dispatchers have stable reference

The most notable examples of stable variables are setState from useState() and dispatch from Redux. Dispatchers from other React libs are usually expected to be stable.

useCallback and useMemo

When you feed the dependency array with variables you have created, you can double-check if those variables just change their references when their underlying data changes. Check opportunities of making your variables' references more stable with the help of useCallback and useMemo. Forgetting to use useCallback on a function and then feeding it to useEffect can lead to a disaster.

Depend on primitives

Even if an object might have changed its reference, one specific property might have stayed the same. So, when possible, it is interesting to depend on specific properties instead of on a whole object.

Use setState's callback form

We can get rid of dependencies by using the callback form from setState.

const [state, setState] = useState({ id: 2, label: 'Jessica' })

// good
useEffect(() => {
  setState(previous => ({ ...previous, name: 'Jenn' }))
}, [])

// bad
useEffect(() => {
  setState({ ...state, name: 'Jenn' })
}, [state])
Enter fullscreen mode Exit fullscreen mode

In this particular case, we were able to remove the state variable from the array (setState is already recognized as stable by the plugin).

Split into smaller effects

We previously said that useEffect is made to handle derived state.

Let's say we have an effect which updates A and B based on 1 and 2.

1, 2 <-- A, B
Enter fullscreen mode Exit fullscreen mode

Maybe A depends on 1 but not on 2? In this case, we can split a big useEffect into smaller ones.

1 <-- A
2 <-- B
Enter fullscreen mode Exit fullscreen mode

Intermediary dependencies

Effect splitting can also be achieved by identifying intermediary dependencies.

Example before refactoring:

function Component({ userId, event }: { userId: number, event: Event }) {
  const [subscriptionIsExpired, setSubscriptionExpired] = useState(false)
  useEffect(() => {
    const userSettings: { validUntil: string } = await getUserSettings(userId)
    const isExpired = event.startDate > userSettings.validUntil
    setSubscriptionExpired(isExpired)
  }, [userId, event])
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the getUserSettings() request will be called when event changes. But it actually has nothing to do with the event. We may refactor that to:

function Component({ userId, event }: { userId: number, event: Event }) {
  const [userSettings, setUserSettings] = useState<null|UserSettings>(null)
  const [subscriptionIsExpired, setSubscriptionExpired] = useState<null|boolean>(null)

  useEffect(() => {
    const userSettings: { validUntil: string } = await getUserSettings(userId)
    setUserSettings(userSettings)
  }, [userId])

  useEffect(() => {
    if (!userSettings) {
      return
    }
    const isExpired = event.startDate > userSettings.validUntil
    setSubscriptionExpired(isExpired)
  }, [userSettings, event])

  return (...)
}
Enter fullscreen mode Exit fullscreen mode

Now the async request only depends on userId. The second effect continues to depend on both userId (through userSettings) and event.

from:
userId, event <-async-- isExpired

to:
userId <-async- userSettings
event, userSettings <-- isExpired
Enter fullscreen mode Exit fullscreen mode

Offtopic: Loading states

When data is fetched asynchronously, an extra state type arises: the "loading" state. In the example above, the loading state is represented by null. By aknowledging that some state can be null (see the useState line), TypeScript can help us handle edge cases with its null checks.

If we feel that one component became way complex because of multiple loading states, we can choose it to only render when everything is ready; For instance:

  • Split the component into a parent and a child;
  • Specify that the child receives the data as non-nullable props. Effectively this removes loading states handling from the child;
  • Do prior data-fetching in the parent component, which will have the nullable state; Return a loading widget until everything has loaded;
  • Return the child component when everything is ready;

I actually want to only run an effect once, even if I receive new values

This can still be done without the need for the eslint-disable by copying the dependency to a state or to a ref.

function Component({ id }) {
  // gets the value from the first render
  const [initialId] = useState(id) // or useState(() => id)
  useEffect(() => {
    // ...
  }, [initialId])
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

While this is ugly, it is better than manually picking the dependencies because it is explicit. You are explicitly freezing the value of the variable to the one which came on the first render. Attention: acknowledge the the component won't respond to prop changes, which may cause bugs.

Avoiding useEffects

(new section added as of 2022/10)

Better than optimizing useEffects is avoiding them at all, when possible.

Sometimes this can be achieved by calculating the dependencies on dispatch time.

For instance, let's say document depends on name, one could write:


function handleChange(event) {
  const { name, value } = event.target

  setState(previous => {
    const result = { ...previous, [name]: value }
    if (name === 'name') {
      result.document = getDocumentFromName(name)
    }
    return result
  })
}
Enter fullscreen mode Exit fullscreen mode

The case for useRef

React Refs behave a bit differently from React states:

  • A state is tied to a render through lexical scope. Each render can reference a different state object from a different slice of time; This may have impact on future concurrent render modes?

  • A ref is just a property tied to the component. ref.current will always point to the same thing and will always be current, regardless of where you call it;

It is a bit dangerous to talk abour refs without giving possibly wrong advice. Refs are analogous to setting a property in a class component (instead of setting a state), and doing that was considered anti-pattern at the time.

Disclaimers being said, refs are not counted as dependencies for useEffect, so you could get rid of a dependency by turning it into a ref. I'd pin out the following properties of something that can likely be turned into a ref:

  • It is a value that it is not directly used in the rendered content;
  • Thus, when you change it, you don't want a re-render;
  • It is used as a bridge between multiple events on the same component, for instance: communication between multiple effects, outbound and inbound events;

Refs are also used to read values from previous renders and to write advanced memoing hooks as present in popular hooks collections.

Extra: The force render and force effect hacks

An effect can programatically be triggered by receiving a "signal reference".

const [trigger, forceEffect] = useState({})
useEffect(() => {

}, [trigger])

return <button onClick={() => forceEffect({})}>
  Force effect
</button>
Enter fullscreen mode Exit fullscreen mode

References

Nick Scialli - You probably shouldn't ignore react-hooks/exhaustive-deps linting warnings (prev google research)

Top comments (0)