Max Schmitt

July 15 2021

Creating React Components that can be Controlled and Uncontrolled

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.

What is an Uncontrolled React Component?

A React component is uncontrolled if you don't pass it a prop with the state of its value.

JS

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

What is a Controlled React Component?

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.

JS

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.

Building a Custom TextField React Component

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:

JS

function TextField({ showLetterCount = false, ...rest }) {
const [value, setValue] = useState('')
const letterCount = value.length
const 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.

JS

function TextField({ value, onChange, showLetterCount = false, ...rest }) {
const letterCount = value.length
return (
<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:

JS

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?

Making our TextField Component Optionally Controlled

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:

  • If an input is rendered with a value prop that is not undefined, it is controlled
  • An input can accept a value prop or a defaultValue prop but not both
  • An input isn't allowed to switch from being rendered as controlled to being rendered as uncontrolled at runtime (e.g. by starting out with a value = "" and then setting value = undefined at some point.)
  • An uncontrolled input can still have an onChange handler

I've created a little demo on CodeSandbox that showcases these behaviours.

Here is how our TextField component might support all those requirements from above:

JS

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 : internalValue
const letterCount = value.length
const 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.

Links