Dependency abuse with React hooks

React hooks make some people frustrated and not without a reason.

With hooks, some patterns you were used to, simply stop working. In this post, I'd like to suggest a slightly different approach to function handling in React components that is recommended in most articles.

Imagine a simplified QuizPage component that looks like the following:

export default function QuizPage(props) {
  let {quiz} = props

  let [done, setDone] = useState(false) // finished or not finished
  let [selected, setSelected] = useState({}) // selected options
 
  return <Quiz {...props}
               done={done} setDone={setDone}
               chosen={chosen} setChosen={setChosen}/>
}

I delegated rendering to another Quiz component to simplify explanation. The QuizPage deals only with state/logic while Quiz takes the rendering part which is a reasonable separation.

Btw. you would have the above code structure with NextJS React framework, which I highly recommend.

Now say we want to store the quiz result into a session storage. How would we do that?

We start naively:

export default function QuizPage(props) {
  let {quiz} = props

  let [done, setDone] = useState(false)
  let [selected, setSelected] = useState({})

  // +++
  function loadFromSession(slug) {
    let data = sessionStorage.getItem("progress:" + slug)
    if (data) {
      let obj = JSON.parse(data)
      setDone(obj.done)
      setSelected(obj.selected)
    }
  }
   
  // +++  
  function saveToSession(slug) {
    let json = JSON.stringify({
      done, tabIndex, chosen
    })
    sessionStorage.setItem("progress:" + slug, json)  
  }

  // +++
  useEffect(() => {
    loadFromSession(quiz.slug)
    return () => {
      saveToSession(quiz.slug)
    }
  }, [quiz.slug])
 
  return <Quiz {...props}
               done={done} setDone={setDone}
               selected={selected} setSelected={setSelected}/>
}

The above is clearly simplified as I don't check for window.sessionStorage, etc. It should be good enough for demonstration purposes, though.

Our code doesn't work as soon as we have two quizzes.

useEffect captures the "wrong" saveToSession. The [quiz.slug] part prevents useEffect from loading and saving data on each render, which we want. But, at the same time, it prevents useEffect from capturing the newer versions of functions, which introduces a bug.

At this point, a newcomer starts to dispair and then searches the internet. She sees the useCallback function being glorified in many tutorials. So she changes her code to something like this:

export default function QuizPage(props) {
  let {quiz} = props

  let [done, setDone] = useState(false)
  let [selected, setSelected] = useState({})

  // ***
  let loadFromSession = useCallback(slug => {
    let data = sessionStorage.getItem("progress:" + slug)
    if (data) {
      let obj = JSON.parse(data)
      setDone(obj.done)
      setSelected(obj.selected)
    }
  }, [...deps...])

  // ***
  let saveToSession = useCallback(slug => {
    let json = JSON.stringify({
      done, tabIndex, chosen
    })
    sessionStorage.setItem("progress:" + slug, json)  
  }
  }, [...deps...])

  useEffect(() => {
    loadFromSession(quiz.slug)
    return () => {
      saveToSession(quiz.slug)
    }
  // ***
  }, [quiz.slug, loadFromSession, saveToSession]) 
  
 
  return <Quiz {...props}
               done={done} setDone={setDone}
               selected={selected} setSelected={setSelected}/>
}

Now it doesn't look like anything familiar anymore. While the code evolves, the pain of dependency juggling becomes more and more prominent.

It's easy to make a mistake and spend hours debugging the bugs with asynchronicity and race conditions. The code is just too complex for what it should be for such a trivial task!

But what if we take another approach to "unfreezing" useEffect. What if instead of dancing around reactive dependencies we simply emulate OOP approach? How it would look like?

export default function QuizPage(props) {
  let {quiz} = props

  let [done, setDone] = useState(false)
  let [selected, setSelected] = useState({})

  let self = useRef()  

  // +++
  useEffect(() => {
    self.current = { 
      loadFromSession(slug) {
        let data = sessionStorage.getItem("progress:" + slug)
        if (data) {
          let obj = JSON.parse(data)
          setDone(obj.done)
          setSelected(obj.selected)
        }
      },

      saveToSession(slug) {
        let json = JSON.stringify({
            done, tabIndex, chosen
        })
        sessionStorage.setItem("progress:" + slug, json)  
      })
    })
  })

  useEffect(() => {
    self.current.loadFromSession(quiz.slug)
    return () => {
      self.current.saveToSession(quiz.slug)
    }
  }, [quiz.slug]) // !!!
 
  return <Quiz {...props}
               done={done} setDone={setDone}
               selected={selected} setSelected={setSelected}/>
}

Whoops! Notice how all the reactive mess is gone and only [quiz.slug] which concerns the real business logic is left.

Right tool for the right job!

I very much prefer this version to the previous and honestly I think useCallback is overused. And one of the reasons it's overused is that memoization is overrated in React community. But that is a story for another day.

I think you cross the line of readability/reasonability when your start passing functions as dependency arguments of useEffect and co. And that is something some influential bloggers recommend which I'm slightly worried about.