Dont Depend On State From Callback Handlers in React
jsmanifest
Posted on April 21, 2020
Find me on medium
Join my newsletter
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.log
s 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)
})
}
}
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:
This is react's render phase in our examples (You can think of this as react having their own execution context):
With that said, let's take a look at our call to setCollapsed
again:
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:
Find me on medium
Join my newsletter
Posted on April 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.