Enforcing explicit type safety in React state:an opinionated approach to useState
Motivation
Managing states in React can be a little bit tricky sometimes, specially when
building upon big layers of abstractions. Sometimes, it becomes hard to
distinguish between a proper React state (like those managed by useState
,
useMemo
, useReducer
, etc.) and a simple variable. Which could lead to
unwanted side effects, like re-renders or even worse, bugs.
Got you covered, fam
So I got this idea on my mind for a while, and I think it could be a good think to explicitly type a React state. And it's really really really simple:
import React from 'react'
interface IStateful<T> {
_hiddenMemberToForceTypeScriptToRecognizeThisAsAUniqueTypeForIStatefulWrapper: T
}
export type Stateful<T> = IStateful<T> & T
export const useState: {
<S>(initialState: ((() => S) | S)): [Stateful<S>, React.Dispatch<React.SetStateAction<S>>];
<S = undefined>(): [Stateful<S> | undefined, React.Dispatch<React.SetStateAction<S | undefined>>]
} = React.useState;
export type DependencyList = readonly Stateful<unknown>[];
export const useEffect: (effect: React.EffectCallback, deps?: DependencyList) => void = React.useEffect
And that's really it. I mean, it's not a full wrapper around every single
React hook. But as a concept, it's just that: a private type that we only
use to trick TypeScript into thinking that a Stateful
is a unique type.
A poor man's opaque type, if you will.
Mom, can we have opaque types?
No, we have opaque types at home.
Opaque types at home:
interface IStateful<T> { _hiddenMemberToForceTypeScriptToRecognizeThisAsAUniqueTypeForIStatefulWrapper: T }
Oh, btw, a little side note: it's actually an opaque type with subtyping. So
a Stateful<T>
is a T
, but a T
is not a Stateful<T>
. This is important!!!1
But also it is a pretty nice abstraction, since you can use it as a normal type.
How to use it?
export const someHook = () => {
const [state] = useState(0)
const normalVariable: number = state
useEffect(() => {
if (state === 0) console.log('state is 0')
}, [state, normalVariable])
}
With this, TypeScript will complain about the normalVariable
being a number
and not a Stateful<*>
.
Obviously, there are cases when you truly want to use a normal variable
as a dependency, but in those cases, you can just cast it to a Stateful<*>
,
not a big deal really.
Conclusion
It's just it. Thank you for coming to my TED talk. :)