Dont Depend On State From Callback Handlers in React

Christopher T.

April 19th, 2020

Share This Post

Thinking in React's Render Phase As Opposed to JavaScript's Execution Context

If you've been a react developer for awhile you probably might agree with me that working with state can easily become the biggest pain in the rear of your day.

So here's a tip that might help keep you in check for introducing silent but catastrophic errors: Avoid closures referencing state values from their callback handlers.

If done right, you should have no problems dealing with state in callback handlers. But if you slip at one point and it introduces silent bugs that are hard to debug, that's when the consequences begin to engulf that extra time out of your day that you wish you could take back.

With that said, we're going to look at an issue in code that will show us a common problematic scenario when we work with state. The code example ahead will show a component App. It will declare a collapsed state (defaulted to true) and renders an AppContent component which renders the input element.

function AppContent({ onChange }) {
  const [value, setValue] = React.useState('')

  function handleOnChange(e) {
    if (onChange) {
      onChange(function({ person, collapsed }) {
        console.log(collapsed)
        console.log(person)
        setValue(e.target.value)
      })
    }
  }

  return (
    <input placeholder="Your value" value={value} onChange={handleOnChange} />
  )
}

function App() {
  const [collapsed, setCollapsed] = React.useState(true)

  function onChange(callback) {
    const person = collapsed ? null : { name: 'Mike Gonzalez' }
    callback({ person, collapsed })
  }

  return (
    <div>
      <AppContent
        onChange={(cb) => {
          setCollapsed(false)
          onChange(cb)
        }}
      />
    </div>
  )
}

When a user types in something it will call its onChange handler from props which is directed to App. It will receive the callback argument and sets its collapsed state to false so that its children can expand to display their content. Then the execution ends up inside handleOnChange (the callback), passing in collapsed and a random person variable (Yes, random I know) that is populated with data only if collapsed is false.

The code actually runs fine with no unexpected console errors, and life is well.

Actually, there's a major issue in this code. The fact that we're thrown off with no console errors and that our code isn't breaking already makes it a dangerous bug!

Lets add some console.logs inside handleOnChange and see what we get:

function handleOnChange(e) {
  if (onChange) {
    onChange(function({ person, collapsed }) {
      console.log(`collapsed: ${collapsed}`)
      console.log(`person: ${JSON.stringify(person)}`)
      setValue(e.target.value)
    })
  }
}

handling-state-in-callback-handlers1

Wait a minute, why is person null and collapsed true? We already set the state value of collapsed to false and we know this is valid JavaScript code since the runtime was able to proceed without problems:

return (
  <div>
    <AppContent
      onChange={(cb) => {
        setCollapsed(false)
        onChange(cb)
      }}
    />
  </div>
)

If you understand the execution context in JavaScript this makes no sense because the function that encapsulates the call to setCollapsed had finished before sending the call to its local onChange function!

Well, that is actually still right. There's nothing JavaScript doing is wrong right now. It's actually react doing its thing.

For a full explanation of the rendering process you can head over to their documentation.

But, in short, basically at the time whenever react enters a new render phase it takes a "snapshot" of everything that is present specific to that render phase. It's a phase where react essentially creates a tree of react elements, which represents the tree at that point in time.

By definition the call to setCollapsed does cause a re-render, but that render phase is at a future point in time! This is why collapsed is still true and person is null because the execution at that point in time is specific to that render, sorta like having their own little world that they live in.

This is how the concept of execution context looks like in JavaScript:

javascript-execution-context-diagram-flow-chart

This is react's render phase in our examples (You can think of this as react having their own execution context):

react-render-phase-reconciliation-flow-chart

With that said, let's take a look at our call to setCollapsed again:

updating-state-in-render-phase-in-react

updating-state-in-render-phase-in-react2

This is all happening in the same render phase so that is why collapsed is still true and person is being passed as null. When the entire component rerenders then the values in the next render phase will represent the values from the previous:

react-state-update-reconciliation2


Tags

javascript
react
closure
execution context
render
render phase
reconciliation

Subscribe to the Newsletter
Get continuous updates
© jsmanifest 2021