Controlled vs Uncontrolled React Data
TL;DR: When a React component is rendering a value, that value should either be controlled (gets the value from props) OR uncontrolled (keeps the value in state).
Intro
When designing a React component, for each piece of data you want to render, you’ll need to decide if it’s going to be controlled or uncontrolled1. People get confused about what that means, so I’ll explain it in this post. Knowing the difference will help you design more predictable and understandable component interfaces.
As simply as possible: “controlled” means the value comes from props, and “uncontrolled” means the value is stored in state2. (The terms are a bit confusing, because here it refers to the parent component: controlled means the value is controlled by the parent component, and uncontrolled means the parent component doesn’t have control. It’s annoying and I have to re-think which one we’re talking about in my head each time, but I don’t make up the names 🙃 ) Examples below:
// Controlled example - AddressInput's parent controls the address value
function AddressInput(props) {
return (
<input
value={props.address}
onChange={(e) => props.updateAddress(e.target.value)}
/>
);
}
// Example usage -> see the control?
<AddressInput
address="221B Baker Street"
updateAddress={(newAddress) => {/* store the value somewhere */}}
/>
// Uncontrolled example - AddressInput keeps the value it state and updates it
function AddressInput() {
const [address, setAddress] = useState("221B Baker Street");
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
)
}
// Example usage -> parent has no control
<AddressInput />
What Could Go Wrong?
The problems start when mixing the two approaches. This is a bad pattern that you should stop doing, and I’ll go through why. The most common use case is for the parent component to provide a default or initial value (totally reasonable), and then the child takes over and controls the value after that. A first attempt often looks like this:
function AddressInput(props) {
// Initializes state with prop value
const [address, setAddress] = useState(props.address);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
)
}
// Example usage
<AddressInput address="221B Baker Street" />
The problem here is that if the parent component updates the address
prop, AddressInput
will completely ignore the update - it only gets used on the inital render to set state.
// BAD
function AddressInput(props) {
// Any future updates to props.address will be ignored
const [address, setAddress] = useState(props.address);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
)
}
// Example usage
<AddressInput address="221B Baker Street" />
Now, you might argue that that’s the whole point, and is how you get the “initial value” behavior, but this breaks our general expectation of how React components work: as a developer using a component in a library, I will reasonably assume that updating the prop will update the rendered value, when surprise! the component isn’t responding to updates to the prop at all. You could try to fix this by responding to changes to the prop and updating state again.
// FIXED?
function AddressInput(props) {
// Set state initially to props.address
const [address, setAddress] = useState(props.address);
useEffect(() => {
// If props.address changes, update state again
if (props.address !== address) {
setAddress(props.address);
}
});
return (
<input
value={address}
// Respond when user changes the value
onChange={(e) => setAddress(e.target.value)}
/>
);
}
// Example usage
<AddressInput address="221B Baker Street" />
“Aha! I have solved the problem!” you’ll claim. And while this is technically true, oh god why would you want to have two sources of truth for a value?
// EVEN WORSE
function AddressInput(props) {
const [address, setAddress] = useState(props.address);
useEffect(() => {
if (props.address !== address) {
// This update will overwrite any changes from state
setAddress(props.address);
}
});
return (
<input
value={address}
// This update will overwrite any changes from props
onChange={(e) => setAddress(e.target.value)}
/>
);
}
// Example usage
<AddressInput address="221B Baker Street" />
Either the value should live somewhere outside of the component (its parent’s state, a Redux or Apollo store, the server, whatever), or it should live inside the component - not both. When you mix them, changes from props will blow away changes from state, and vice versa.
How to Fix It
The solution is to use a prop called initialAddress
instead:
// GOOD
function AddressInput(props) {
const [address, setAddress] = useState(props.initialAddress);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
);
}
// Example usage
<AddressInput initialAddress="221B Baker Street" />
You’ll notice that the only difference between this and the BAD
example above is naming - I haven’t changed anything structural about how the component works. But we’ve now successfully communicated our intent, and that’s what’s important. A developer reading through code is now much less likely to make a mistake.
Extra Credit
To go one step further, I’d argue that even in the GOOD
example, we still aren’t separating our concerns well: why should the parent of AddressInput
need to know the string for the default value? Presumably we made it a prop because we expect it to change from call to call. This means there’s some business logic for when to have one initial value vs another. However, it’s unlikely that the parent callsites actually care what the literal string value is - they just know the context. Try passing in the context instead, and delegate the business logic for mapping contexts to string values to the component that cares about them:
// EVEN BETTER
const initialAddressesByLocation = {
UK: '221B Baker Street',
USA: '350 Fifth Avenue',
Shire: 'Bag End, Hobbiton',
};
function AddressInput(props) {
const [address, setAddress] = useState(initialAddressesByLocation[props.location]);
return (
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
);
}
// Example usage
<AddressInput location="Shire" />
Now, you can centralize the reasons for having different initial addresses in a single place, and callsites only have to know what context they’re being rendered in3. Additionally, if multiple callsites share a default value, now you can update it in one place.
In summary
- A rendered value should live in and be updated by props OR state, but never both.
- Use clear prop naming to communicate intent when setting a default value.
- Try passing down context rather than values to centralize business logic and separate concerns.
For further reading, I recommend the React blog post about when to use derived state, and when not to. Happy coding!
-
Usually, you’ll hear about a controlled or uncontrolled component, but the controlled/uncontrolled status really applies to a piece of data, since a component could have some items that are controlled and some that are uncontrolled, which is fine. ↩
-
There’s another interpretation of controlled vs uncontrolled, where “controlled” means the data is held in state, and “uncontrolled” means the data is updated by the DOM. I’m focusing on the parent<>child component definition in this post. ↩
-
This works especially well with TypeScript, where you can use an enum to define the allowed options, and use the enum at the callsites to prevent typos. ↩