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 { Dispatch, SetStateAction } from "react";
import { useUncontrolled } from "uncontrollable";
type UIInputProps = {
defaultValue?: string;
value?: string;
onChange?: Dispatch<SetStateAction<string>>;
};
function UIInput(props: UIInputProps) {
/*
PROPS
*/
const {
value,
onChange,
... rest
} = useUncontrolled(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 useUncontrolled 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
// } = useUncontrolled(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.