DEV Community

Kelley van Evert
Kelley van Evert

Posted on

An uncontrollabilizer for React

I just wanted to share my take on making a quick & easy, and hopefully reasonably TypeScript-proof version of jquense's uncontrollable, but written with the sensibility of Yury's stackoverflow answer.

The use-case? You have a React component that houses one, or maybe even a number, of stately values. You would want to both be able to delegate control over to a controlling component, but also want to be able to take the reins yourself if the surrounding component does not want to control you.

1/2 As a HOC

The idea: just write your component as if it's fully controlled, and then use a dead simple HOC wrapper that fills in any necessary state management. The only hard part really, is getting the types right. (Sadly, Exclude<string, keyof P> is just string again, so that doesn't actually work.)

Here's a CodeSandbox example.

And here's the code:

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// A "type function" that computes an uncontrolled version
//  of controlled component's props type, given
//  a value key, an onchange key (both currently part
//  of the props type), and a default value key (which should
//  be freshly chosen)
type Uncontrolled<
  P, // controlled component's props
  VK extends keyof P, // value key
  OCK extends keyof P, // onchange key
  DK extends Exclude<string, keyof P> // default value key
> = Omit<P, VK | OCK> & { [k in DK]: P[VK] };

// Turns a controlled component into a component that can both
//  be used in a controlled fashion or an uncontrolled fashion
function uncontrollabilize<
  P,
  VK extends keyof P,
  OCK extends keyof P,
  DK extends Exclude<string, keyof P>
>(
  C: React.FunctionComponent<P>,
  valueKey: VK,
  onChangeKey: OCK,
  defaultKey: DK
): React.FunctionComponent<P | Uncontrolled<P, VK, OCK, DK>> {
  return function Wrapped(props: P | Uncontrolled<P, VK, OCK, DK>) {
    // Using a flag which is only set once, to disable switching between
    //  controlled and uncontrolled usage of the same instance.
    const isControlled = useRef<boolean>(valueKey in props).current;

    // It would be theoretically more correct, and type-check,
    //  if this state initialization only occurs in the
    //  else branch below. But then it's less clear that
    //  the hook is always, or always-not, called.
    // So, stability first.
    const defaultValue = (props as any)[defaultKey];
    const [value, set_value] = useState<P[VK]>(defaultValue);

    if (isControlled) {
      return <C {...props as P} />;
    } else {
      const controllerProps = {
        [valueKey]: value,
        [onChangeKey]: set_value
      };
      return (
        // @ts-ignore
        <C {...props as Uncontrolled<P, VK, OCK, DK>} {...controllerProps} />
      );
    }
  };
}

2/2 As a Hook

And of course there's a hook version of that, which is way shorter and nicer to the eye :D But it does have a slight loss of appeal in not allowing you to type your component's props rigorously. That is, you need to make all three props (value, onChange, defaultValue) optional.

Here's a CodeSandbox example.

function useUncontrollizable<T>(
  val?: T,
  set_val?: (newVal: T) => void,
  default_val?: T
): [T, (newVal: T) => void] {
  const isControlled =
    typeof val !== "undefined" && typeof set_val !== "undefined";
  const control = useState<T>(default_val);
  return isControlled ? [val, set_val] : control;
}

Top comments (0)