Page main photo
🛡️

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. :)