Create React UI component with uncontrollable
If you’re writing a UI library in React, a package you really should know is uncontrollable.
What does it do you may ask? It does a lot but to explain it’s quite messy so let’s start with an example instead.
Cheatsheet
This is uncontrollable in action:
import { SetStateAction } from "react";
import { useUncontrolled } from "uncontrollable";
type UIInputProps = {
defaultValue?: string;
value?: string;
onChange?: SetStateAction<string>;
};
function UIInput(props: UIInputProps) {
/*
PROPS
*/
const {
value,
onChange,
... rest
} = useUncontrollable(props, {
value: "onChange"
});
return (
<input
value={value}
onChange={(e) => {
onChange(e.target.value);
}}
{...rest} />
);
}
export default UIInput;
This doesn’t seem much?
Look better.
This allows you to expose 3 optional props in your component value
, defaultValue
, onChange
and not worry about them.
Just because you’re having useUncontrollable
you covered 8 cases basically, from all undefined
to all set.
Not bad at all.
So what’s the original problem?
Let’s say you’re writing your UI component library and you want to provide a bunch of props that may or may not be used.
An example would be a UIInput
component with defaultValue
, value
and onChange
in all sorts of combinations.
// Case: defaultValue only
<UIInput
name="email"
defaultValue={user.email}
onChange={(email) => ...} />
// Case: value only
<UIInput
name="email"
value={value}
onChange={(email) => setValue(email)} />
Now, to support these cases you may have code like this:
function UIInput(props) {
/*
PROPS
*/
const {
value,
defaultValue = '',
onChange,
... rest
} = props;
/*
INTERNAL
*/
const isControlled = value !== undefined;
/*
STATES
*/
const [
internalValue,
setInternalValue
] = useState(defaultValue);
/*
EFFECTS
*/
useEffect(() => {
if (isControlled) {
setInternalValue(value);
}
}, [value, isControlled]);
/*
FUNCTIONS
*/
const handleChange = (e) => {
const newValue = e.target.value;
if (!isControlled) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
return (
<input
value={isControlled ? value : internalValue}
onChange={handleChange}
{...rest} />
);
}
export default UIInput;
You see how much trouble you have with props
, useState
, useEffect
to keep internal and external states in sync?
This can be quite noisy in a way.
Well ,that’s what uncontrollable
does, it saves you from all that boilerplate.
What’s under the hood?
Ok, apart from what it does for you, the other nice thing is that uncontrollable
is 92 lines of code which makes it quite accessible to understand.
So let’s dive in.
There are two main functions/hooks in it: useUncontrolledProp
and useUncontrolled
.
Let’s start with useUncontrolledProp
.
// Reindented and removed some types for brevity
function useUncontrolledProp(
propValue: TProp | undefined,
defaultValue: TProp | undefined,
handler?: THandler
) {
const wasPropRef = useRef<boolean>(propValue !== undefined);
//
// 1. create the internal state
// and init to default value
const [stateValue, setState] = useState<TProp | undefined>(defaultValue);
const isProp = propValue !== undefined;
const wasProp = wasPropRef.current;
wasPropRef.current = isProp;
/**
* If a prop switches from controlled to Uncontrolled
* reset its value to the defaultValue
*/
if (!isProp && wasProp && stateValue !== defaultValue) {
setState(defaultValue);
}
return [
isProp ? propValue : stateValue,
useCallback(
(...args: Parameters<THandler>): ReturnType<THandler> | void => {
const [value, ...rest] = args;
//
// 2. if external setState pass value
// and keep internal state in sync
let returnValue = handler?.(value, ...rest); << if external
setState(value);
return returnValue;
},
[handler]
),
] as const;
}
The other function is useUncontrolled
which is:
// Again reindented and removed the types for brevity
export function useUncontrolled(props: TProps, config: ConfigMap<TProps>): Omit<TProps, TDefaults> {
//
// assuming you have
// const config = { value, "onChange"};
// const {
// value,
// onChange,
//
// ... rest
// } = useUncontrollable(props, config);
//
return
Object
.keys(config) // << each key, in this case value
.reduce((result: TProps, fieldName: string) => {
// for value this is
//
// const {
// defaultValue: defaultValue,
// value: propsValue
// ...rest << these are the rest of the initial props
// } = result as any;
//
// This is because
//
// defaultKey(fieldName) >> defaultValue
// fieldName >> value
const {
[defaultKey(fieldName)]: defaultValue,
[fieldName]: propsValue,
...rest
} = result as any;
// onChange = config["value"];
const handlerName = config[fieldName];
// we saw before the internal of useUncontrolledProp
const [value, handler] = useUncontrolledProp(
propsValue,
defaultValue,
props[handlerName] as Handler
);
// re-merge value and onChange
return {
...rest,
[fieldName]: value,
[handlerName]: handler,
};
}, props);
Summary
uncontrollable is a package with millions of downloads used by packages like react-widget
and react-bootstrap
.
If you’re writing a UI component library in React, uncontrollable helps you dealing with the boilerplates React force you into to manage internal and external states.
A symbolic case is when you expose defaultValue
, value
and onChange
or a show
, onToggle
on your component.
If you are interested in the use case and you want to see more, you can see react-widgets/src/Autocomplete.tsx for useUncontrolledProps
and react-bootstrap/src/Nav.tsx for useUncontrolled
as and extra example.