react hooks tripwires

18957 words reactjavascriptreact

image released to the public domain by The U.S. National Archives

React hooks are simple and powerful, but like so much things that are simple and powerful, have inherent tripwires that one can get easily ensnared in.

  • hooks evaluations: too few or not at all or too many
  • unsubscribed too early
  • state changes after unmount

When is my hook evaluated?

  • useRef - never, you just get a memoized reference
  • useCallback, useEffect - on mount, on unmount and every time a dependendency differs from the previous ones
  • useLayoutEffect - same as use effect, but only after the component in question is rendered

It is easy to overlook if an update is missing, superfluous or even causing a loop.

Missing or no updates

Three errors can lead to missing updates:

  1. using useRef instead of useState to keep track of state values that should trigger a change
  2. forgetting a state value that should trigger a change in the hook's dependencies
  3. using the state of another component that is not a parent of the current component, thus not trigger a render cycle on change

While the solutions for the first two is obvious, the third one has no ideal solution. One might be able to pull the state to the parent or use a context instead.

Superfluous updates

Consider the following example of a countdown hook:

const useCountdown = (props) => {
  const [time, setTime] = useState(props.delay)

  useEffect(() => {
    const interval = setInterval(() => {
      if (time <= 0) {
        props.onEnded()
        clearInterval(interval)
      } else {
        setTime(time - 0.1)
      }
    }, 100)
    return () => clearInterval(interval)
  }, [time, props.onEnded])

  return time
}

Every time time changes, the previous evaluation's unsubscribe is called and the hook is evaluated anew - every tenth of a second. In these cases, the ability of setState to evaluate a function is really helpful:

const useCountdown = (props) => {
  const [time, setTime] = useState(props.delay)

  useEffect(() => {
    const interval = setInterval(() => {
      setTime((time) => {
        if (time <= 0) {
          props.onEnded()
          clearInterval(interval)
        }
        return time - 0.1
      })
    }, 100)
    return () => clearInterval(interval)
  }, [props.onEnded])

  return time
}

Now we can lose time from the useEffect dependencies, thus avoiding superfluous evaluations.

Another class of superfluous updates can happen if you are assigning functions, arrays, objects, instances etc outside of memoization which can be fixed by useCallback, useMemo, useRef.

Unsubscribed too early

const useGlobalClick = (props) => {
  useEffect(() => {
    document.addEventListener('click', props.handler)
    return document.removeEventListener('click', props.handler)
  }, [props.handler])
}

const useSubscription = (props) => {
   useEffect(() => {
     const subscription = props.observable.subscribe(props.handler)
     return subscription.unsubscribe()
   }, [props.observable, props.handler])
}

Can you spot the errors?

On the first one, it should have been return () => document.removeEventListener…, on the second example, it should have either been return subscription.unsubscribe or return () => subscription.unsubscribe(). If you want to make sure, make a habit of always returning an anonymous function.

State changes after unmount

If you are handling asynchronous effects, for example a fetch request, a promise or waiting for a callback to be evaluated, then it may so happen that the event that ends your wait is only after the component using your effect has been unmounted.

const List = () => {
  const [items, setItems] = useState([])

  useEffect(() => {
    fetch('my-items')
      .then((response) => response?.json())
      .then((items) => setItems(items ?? []))
  }, [])

  return items 
    ? <ul>
        {items.map((item) => <li>{item}</li>)}
      </ul>
    : null
}

If your asynchronous action can be aborted and you benefit from doing so (e.g. a request that would otherwise slow down others), you should do so:

const List = () => {
  const [items, setItems] = useState([])

  useEffect(() => {
    const controller = new AbortController()
    fetch('my-items', { signal: controller.signal })
      .then((response) => response?.json())
      .then((items) => setItems(items ?? []))
    return () => controller.abort()
  }, [])

  return items 
    ? <ul>
        {items.map((item) => <li>{item}</li>)}
      </ul>
    : null
}

But what if it isn't? Use a ref to store your setter and null it on unmount:

const List = () => {
  const [items, _setItems] = useState([])
  const setItems = useRef(_setItems)

  useEffect(() => {
    fetch('my-items')
      .then((response) => response?.json())
      .then((items) => setItems.current?.(items ?? []))
    return () => { setItems.current = null }
  }, [])

  return items 
    ? <ul>
        {items.map((item) => <li>{item}</li>)}
      </ul>
    : null
}

Thank you for reading this. If you know other common tripwires, please tell me in the comments!