A slightly better useState
David Khourshid recently gave an interesting talk about avoiding useState when you don’t need it:
I agree with a lot of what David says here, but there’s one problem he doesn’t touch on, which I’ll refer to as React Closure Hell.
This is one of the things I feel like is a constant source of pain in React Function Components: it’s too hard to keep track of what is currently in closure scope, and too easy to get old values from closure scope that have already changed by the time you need them.
The most obvious example is if you do something asynchronous, then try to read from state in closure scope. You’ve probably run into something like this:
Once that getPageData()
call completes, you have no idea whether the state has changed, so you’re at the mercy of having to use whatever value was stored in closure scope at the specific time your useEffect
handler was called.
A lot of the time this won’t cause any huge issues when you test; but when it does, you’ll have a race condition that will be a nightmare to debug.
There are a ton of ways to land in Closure Hell in React, and I’ve yet to encounter a silver-bullet which avoids it entirely.
But when this really irritates me is when I’m trying to use useState
with a default value, and I completely unnecessarily end up having to deal with old default values sticking around in closure scope for no good reason.
I’m a simple man, so I quite like reaching for useState
when I first start building a component. But the number of times I’ve fallen into this particular trap is maddening:
What’s the bug here? It’s this: when the defaultInputValue
prop changes, nothing happens — React doesn’t update the value of my input field, even if I haven’t manually changed or typed into it. This is because the default value for the inputValue
state has already been set. So the new prop is just ignored.
Quick aside — yes, I know this example isn’t technically an ‘old closure scope’ issue — but for me it’s a fresh sprinkling of salt in the very same wound
I am constantly finding myself with small bugs as a result of trying to change something in a parent component, passing that down as a prop, but being thwarted by an older value being persisted as a default state.
This can be a problem when using hooks too:
In this case useIsAdmin
depends on an auth token in sessionStorage to determine if a user is an admin. So when the component first renders on the server side, with no sessionStorage, useIsAdmin
just returns false
. Then in the second render on the client side, useIsAdmin
flips to return true
.
But: because I used isAdmin
as the default value for my adminOnlyToggle
state, that state gets locked to false
and doesn’t change, even on the second render when we know the user is an admin.
So, what’s the solution?
To get around this, we built a small custom hook called useDefaultState
. A simplified version of it looks like this:
This hook allows the state to use the latest default value, right up until the point where someone manually set that state to a different value. At that point, the state is locked in place — it stops accepting new default values, and can only be manually set from then onwards.
This small change, along with using useDefaultState
everywhere in our codebase in place of useState
, has killed an unimaginable number of small bugs and race conditions.
Here’s a simplified real-world example from our codebase, where a default state depends on a value from an api. We don’t want to lock in the default value for the state until that api call has a chance to complete:
Are there any downsides?
Not that I can see. useDefaultState
is not especially slower than useState.
It can be combined with useMemo
if your default value is particularly expensive to compute. And to me at least, it feels a lot more predictable and declarative than just locking in the first default value that you happen to pass in to useState
.
Hope you find it useful too!