When using generic React components for UI elements like TextFields and Dropdowns, it's so nice when they adhere to the same API principles as built-in React elements like <input />
.
For example, built-in elements like <input />
, <select />
and <textarea />
all take value
and onChange
props as pairs for when you want to control their state closely (they then act as controlled components). If you don't specify a value
prop, the component will be uncontrolled.
A React component is uncontrolled if you don't pass it a prop with the state of its value.
<input name="email" type="email" defaultValue={user.email} />
A user can type in this uncontrolled input field but you can't easily update the text in the field programmatically (aside from the initial default value).
Uncontrolled inputs can be useful for quickly creating forms where the individual fields and controls don't interact much with each other, but it can be really annoying having to add value/onChange props later on when you need to.
A React component is controlled if you pass it the current state of its value
as a prop. You will then usually also specify an onChange
handler so that you can update the state when the user interacts with the component.
const [email, setEmail] = useState('')const onChange = (e) => {setEmail(e.target.value)}const onClickReset = () => {setEmail('')}return (<><input name="email" type="email" value={email} onChange={onChange} /><button onClick={onClickReset}>Reset</button></>)
In the above example, when the email
state-variable changes, the actual text in the input field on the page will update accordingly.
There is also a reset-button that interacts with the text field. Clicking it causes the email-input to be cleared. This wouldn't be idiomatically possible if the component was uncontrolled.
A common component to spot in a React codebase, is a custom Input
or TextField
component.
Let's see how we can build one that can display the number of letters that the user has typed:
function TextField({ showLetterCount = false, ...rest }) {const [value, setValue] = useState('')const letterCount = value.lengthconst onChange = (e) => {setValue(e.target.value)}return (<div className="TextField"><input value={value} onChange={onChange} {...rest} />{showLetterCount && <p>{letterCount}</p>}</div>)}
This looks good and works but we can't control it easily from the outside. Right now, the TextField
component manages its state internally but we would like to be able to pass value
and onChange
from the outside.
function TextField({ value, onChange, showLetterCount = false, ...rest }) {const letterCount = value.lengthreturn (<div className="TextField"><input value={value} onChange={onChange} {...rest} />{showLetterCount && <p>{letterCount}</p>}</div>)}
By simply moving value
and onChange
to the props, we can now use the TextField in a controlled manner:
const [email, setEmail] = useState('')const onChange = (e) => {setEmail(e.target.value)}return <TextField name="email" type="email" value={email} onChange={onChange} />
But what if we want the controlled behaviour to be optional?
To support both controlled and uncontrolled rendering, we need to accept { value, onChange }
in our props while also being able to manage our TextField's state internally.
Ideally our TextField component will emulate precisely how the built-in React <input />
element behaves:
value
prop that is not undefined
, it is controlledvalue
prop or a defaultValue
prop but not bothvalue = ""
and then setting value = undefined
at some point.)onChange
handlerI've created a little demo on CodeSandbox that showcases these behaviours.
Here is how our TextField component might support all those requirements from above:
function TextField({value: valueFromProps,onChange: onChangeFromProps,defaultValue,showLetterCount = false,...rest}) {// A component can be considered controlled when its value prop is// not undefined.const isControlled = typeof valueFromProps != 'undefined'// When a component is not controlled, it can have a defaultValue.const hasDefaultValue = typeof defaultValue != 'undefined'// If a defaultValue is specified, we will use it as our initial// state. Otherwise, we will simply use an empty string.const [internalValue, setInternalValue] = useState(hasDefaultValue ? defaultValue : '')// Internally, we need to deal with some value. Depending on whether// the component is controlled or not, that value comes from its// props or from its internal state.const value = isControlled ? valueFromProps : internalValueconst letterCount = value.lengthconst onChange = (e) => {// When the user types, we will call props.onChange if it exists.// We do this even if there is no props.value (and the component// is uncontrolled.)if (onChangeFromProps) {onChangeFromProps(e)}// If the component is uncontrolled, we need to update our// internal value here.if (!isControlled) {setInternalValue(e.target.value)}}return (<div className="TextField"><input value={value} onChange={onChange} {...rest} />{showLetterCount && <p>{letterCount}</p>}</div>)}
You can play around with this implementation on CodePen.
It's convenient to be able to render components without having to manage their state; and by following how React's built-in components behave, our component's props-API stays intuitive.
Once you've internalized this "hybrid control" pattern, it won't take you much longer to create your low-level React components but they will be a lot more enjoyable to use.
Hi, I’m Max! I'm a fullstack JavaScript developer living in Berlin.
When I’m not working on one of my personal projects, writing blog posts or making YouTube videos, I help my clients bring their ideas to life as a freelance web developer.
If you need help on a project, please reach out and let's work together.
To stay updated with new blog posts, follow me on Twitter or subscribe to my RSS feed.