Are You a Component Control Freak?
It’s tempting to always control the components we implement. There are many tutorials that teach us about lifting state including React’s own intro tutorial so it’s no wonder we often reach for this approach when working with React.
A controlled component accepts a prop with a corresponding onPropChange
prop and its internal representation of this value is controlled entirely by these two props. For example, a text input has value
and onChange
props. When provided, the input becomes controlled and it is up to us to pass an updated value
when onChange
occurs. If we specified these props but didn't update the value
, the input would not reflect what the user typed.
This pattern works well but things can sometimes be simplified by using an alternate approach.
Uncontrolled components
Uncontrolled components are touched on briefly in the docs and only with a focus on inputs and refs. While this is a good read, it doesn’t particularly help us realise the full potential of the pattern. For example, it is possible to build our own components that can be uncontrolled. So, what makes a component uncontrolled?
Uncontrolled components are components that manage their own internal state to accomplish their concern. They often allow the consumer to specify the initial state and this is usually declared by a prop prefixed with default
.
The docs show examples of uncontrolled inputs using defaultValue
or defaultChecked
props. With an uncontrolled input, we can pass a defaultValue
and we will see this value inside the input initially with subsequent typing reflected without us needing to manage any lifted state.
Building our own
The following is an example of how dumb components are often built. We give them some props and some callbacks and, voilà, we have a dumb Counter
component.
const Counter = ({ count = 0, onChange }) => {
// Effect with `count` dependency so effect runs every time `count` changes
React.useEffect(() => {
setTimeout(() => onChange(count + 1), 1000);
}, [count]);
return <span>{count}</span>;
};
Unfortunately, this will render the number zero but we won't see much counting going on here. So to fix that, we add some state to our parent component.
const App = () => {
const [countOneValue, setCountOneValue] = React.useState(0);
const [countTwoValue, setCountTwoValue] = React.useState(10);
return (
<div>
<p>
<Counter count={countOneValue} onChange={setCountOneValue} />
</p>
<p>
<Counter count={countTwoValue} onChange={setCountTwoValue} />
</p>
</div>
);
};
This gives us two counters that work as expected but they're controlled. Currently, anyone that wants to use the Counter
component will have to implement similar state to make it count.
When you find yourself in this situation, repeating the same lifted state, you will probably benefit from making it an uncontrolled component. To do this, we just move the state from the parent and down into the Counter so it manages its own state.
const App = () => (
<div>
<p>
<Counter />
</p>
<p>
<Counter defaultCount={10} />
</p>
</div>
);
const Counter = ({ defaultCount = 0 }) => {
const [count, setCount] = React.useState(defaultCount);
React.useEffect(() => {
setTimeout(() => setCount(prevCount => prevCount + 1), 1000);
}, [count]);
return <span>{countValue}</span>;
};
We now have a very simple uncontrolled component that behaves as expected all by itself. The consumer no longer needs to control it for it to behave as expected and it has cleared up some repetition and state in our parent component as a result.
Allowing uncontrolled components to be controlled
As we all know, the client will eventually come up with an obscure requirement and ask that some implementations increase by 10
. No problem, we can add a step
prop, but then suddenly they would also like it to increment by multiples of 2
. The consumer is beginning to need more control over the way the counter counts. Fortunately, we can give them the option of that control.
Components can support both controlled and uncontrolled implementations by checking if the consumer has passed the controlled version of the prop, in this case, a count
prop. If they have, we can make the internals switch to using its props to control what's rendered.
const Counter = ({
count: countProp,
defaultCount = 0,
onChange = () => {},
}) => {
// local state for uncontrolled version
const [countState, setCountState] = React.useState(defaultCount);
// whether consumer is trying to control it or not. we use a ref because
// components should not switch between controlled/uncontrolled at runtime
const isControlled = React.useRef(countProp !== undefined).current;
// the count value we render depending on whether it is controlled
const count = isControlled ? countProp : countState;
// maintaining change callback in a ref (more on that later)
const handleChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
handleChangeRef.current = onChange;
});
React.useEffect(() => {
const handleChange = handleChangeRef.current;
setTimeout(() => {
if (isControlled) {
handleChange(count + 1);
} else {
setCountState((prevCount = 0) => {
const nextCount = prevCount + 1;
handleChange(nextCount);
return nextCount;
});
}
}, 1000);
}, [isControlled, count]);
return <span>{count}</span>;
};
This is looking slightly complicated as we need to maintain a handleChangeRef
. We didn't need this before because our effect dependency was only count
so it would only run when the count
changed. However, this new effect needs to depend on the onChange
callback too and if we add that as a dependency, the hook could run every render if someone passes the handler as an inline function.
This approach gives us an updated reference to the handler without adding it as an effect dependency so ensures the effect only runs when count
changes (update: React team have since announced an RFC for a useEvent
hook for this use-case).
More often than not, your onChange
equivalent would be called in response to a user interaction so there would be no need for this effect management, and things would look less complex. I have highlighted the important bits below that you will need to support uncontrolled/controlled versions of your components.
const Counter = ({
count: countProp,
defaultCount = 0,
onChange = () => {},
}) => {
const [countState, setCountState] = React.useState(defaultCount);
const isControlled = React.useRef(countProp !== undefined).current;
const count = isControlled ? countProp : countState;
// ...
if (isControlled) {
onChange(count + 1);
} else {
// if component is uncontrolled, we set the internal state
setCountState((prevCount = 0) => {
const nextCount = prevCount + 1;
onChange(nextCount);
return nextCount;
});
}
// ...
return <span>{count}</span>;
};
We maintain an isControlled
boolean which checks if the controlling prop is not undefined
. We then use the isControlled
boolean to switch the component from managing internal state (uncontrolled), to using the props (controlled).
We've essentially created the equivalent of form fields in React here. A component that can be both controlled or uncontrolled.
Uncontrolled forms
The following is an example of a common approach to forms that I've seen on different projects:
const Contact = () => {
const [name, setName] = React.useState(‘’);
const [email, setEmail] = React.useState(‘’);
const [message, setMessage] = React.useState(‘’);
const [isSubscribed, setIsSubscribed] = React.useState(false);
function handleSubmit(event) {
fetch(‘/contact’, {
mode: ‘POST’,
body: JSON.stringify({ name, email, message, isSubscribed }),
});
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type=“text” value={name} onChange={event => setName(event.target.value)} />
</label>
<label>
Email
<input type=“email” value={email} onChange={event => setEmail(event.target.value)} />
</label>
<label>
Message
<textarea value={message} onChange={event => setMessage(event.target.value)} />
</label>
<label>
<input type=“checkbox” checked={isSubscribed} onChange={event => setIsSubscribed(event.checked)} />
Please check this box if you would like to subscribe to our newsletter
</label>
</form>
);
}
We are maintaining our own state to store input values so that we can later create a JSON string from the form values. Every time a user updates a field, the entire form will re-render. This isn't a huge problem in this particular example but forms can end up more complex than this.
Forms are often a perfect candidate for remaining uncontrolled because they are inherently a data store. Inputs can be uncontrolled and thus, will maintain what the user enters without the need for control. If we name our inputs, we can then use the FormData
API to get the form values when they're needed.
const Contact = () => {
function handleSubmit(event) {
const formData = new FormData(event.currentTarget);
const body = Object.fromEntries(formData.entries());
fetch(‘/contact’, { mode: ‘POST’, body: JSON.stringify(body) });
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type=“text” name="name" />
</label>
<label>
Email
<input type=“email” name="email" />
</label>
<label>
Message
<textarea name="message" />
</label>
<label>
<input type=“checkbox” name="isSubscribed" />
Please check this box if you would like to subscribe to our newsletter
</label>
</form>
);
}
This isn't massively reduced code, but we've simplified our Contact
component by getting rid of the unnecessary state management and prevented the entire form from re-rendering on every keystroke.
Since this is such a common requirement, I often create a reusable Form
component that wraps something similar to the above util and will give me an object literal representation of its fields when its onSubmit
and onChange
events fire.
Uncontrolled paired with the key
prop
Another powerful duo is to combine uncontrolled components with the key
prop from React.
We've all added logic to our components to reset state under certain conditions at one stage or another. The following example resets a comment form when the user selects a different post from a radio list:
const DEFAULT_COMMENT = 'Sounds decent';
const App = () => {
const [selectedPostId, setSelectedPostId] = React.useState();
const [comment, setComment] = React.useState(DEFAULT_COMMENT);
React.useEffect(() => {
// reset state back to original
setComment(DEFAULT_COMMENT);
}, [selectedPostId]);
function handleSubmitComment(event) {
submitComment(comment);
event.preventDefault();
}
return (
<div>
<ul>
{['1', '2', '3'].map(postId => (
<li key={postId}>
<input
type="radio"
value={postId}
onChange={event => setSelectedPostId(event.target.value)}
checked={selectedPostId === postId}
/>{' '}
Post {postId}
</li>
))}
</ul>
{selectedPostId && (
<form onSubmit={handleSubmitComment}>
<h2>Comment on post {selectedPostId}</h2>
<textarea
value={comment}
onChange={event => setComment(event.target.value)}
/>
<br />
<button>comment</button>
</form>
)}
</div>
);
};
You can try this example. Try adding text to the comment box and then when you select a different post, you will see it reset the text back to the default. If you catch yourself writing code to reset state like this, the key
prop can help simplify things.
When the key
prop changes, it re-instantiates your component. We can take advantage of this to return our comment form back to its original state.
const App = () => {
const [selectedPostId, setSelectedPostId] = React.useState();
return (
<div>
<ul>
{['1', '2', '3'].map(postId => (
<li key={postId}>
<input
type="radio"
value={postId}
onChange={event => setSelectedPostId(event.target.value)}
checked={selectedPostId === postId}
/>{' '}
Post {postId}
</li>
))}
</ul>
{selectedPostId && <Comment key={selectedPostId} id={selectedPostId} />}
</div>
);
};
const Comment = ({ id }) => {
function handleSubmitComment(event) {
const formData = new FormData(event.currentTarget);
submitComment(formData.get('comment'));
event.preventDefault();
}
return (
<form onSubmit={handleSubmitComment}>
<h2>Comment on post {id}</h2>
<textarea name="comment" defaultValue="Sounds decent" />
<br />
<button>comment</button>
</form>
);
};
I've made the textarea uncontrolled by passing the initial value as the defaultValue
prop and then used the key
prop to re-instantiate the Comment
component when the selected post changes. This will reset the text in the textarea
. You can see it in action for comparison.
Notice how I no longer need an effect, or an abstracted variable for the default state value, or any comment
state (resulting in reduced rerenders of our App
). Everything is self-contained and explicit. When I first stumbled across this, I thought I was perhaps misusing the key
prop but it turns out that the React team suggest it themselves.
You can do some pretty cool things with this, like this optimistic update craziness I was playing with once upon a time. It dawned on me, uncontrolled components are "optimistic components" because they immediately reflect an attempted change in state. Therefore, instead of controlling a component, maintaining some "previous value" state and resetting it to that value when a request fails, we can use an uncontrolled component with a key
that reinstantiates the component if the request fails. Cool huh?
Conclusion
When you find yourself lifting-state, think for a moment. Do I really need this state? Could you reduce complexity if you use an uncontrolled API? Additionally, if you're trying to get a component into a state that it started out at, perhaps an uncontrolled/key
pairing could help for more lightweight components.
I've personally managed to reduce the complexity in my applications by thinking this way. I hope you find it useful too 🙂