React Anti-Pattern: Stop Passing Setters Down the Components Tree
Before diving in, I have a quick challenge for you. If you're using TypeScript, search your codebase for this partial type definition: React.Dispatch<React.SetStateAction
. If you find occurrences in your component props, drop the count in a comment below—because chances are, you're dealing with an abstraction leak.
In this post, I’ll explain what abstraction leaks are, why they matter, and how to fix them—using a real-world example.
Intro
Recently, I’ve come across some patterns in React projects that raised red flags for me. Let’s look at one of those patterns: passing useState
setters as props.
Here's a typical example:
The following example uses form components purely for illustration purposes; the concepts apply universally and are not tied to the specifics of form handling.
// Form.jsx function Form() { const [formData, setFormData] = useState({ name: '' }); return ( <div> <h1>Form</h1> {/* Pass the setter function down to ChildComponent */} <Input name={formData.name} setFormData={setFormData} /> <button onClick={() => console.log(formData)}>Submit</button> </div> ); }; // Input.jsx function Input({ name, setFormData }) { const handleInputChange = (event) => { // Directly using the setFormData setter function from the parent setFormData((prevData) => ({ ...prevData, name: event.target.value })); }; return ( <div> <label> Name: <input type="text" value={name} onChange={handleInputChange} /> </label> </div> ); };
This code works fine—for now. But let’s dig deeper into why this approach can cause problems as the project evolves.
The evolution of code
Suppose we need to enhance our form. More fields are added, error handling is introduced, and the logic grows more complex. To manage this, we decide to switch from useState
to useReducer
.
Here’s what the refactored Form
component looks like:
function reducer(state, action) { switch(action.type) { case 'setField': return { ...state, [action.payload.fieldName]: action.payload.fieldValue }; case 'setError': return { ...state, error: action.payload.error } default: return state; } } function Form() { const [formData, dispatch] = useReducer(reducer, { name: '', error: null }); return ( <div> <h1>Form</h1> {/* What happens to setFormData now? */} <Input name={formData.name} setFormData={setFormData} /> <button onClick={() => console.log(formData)}>Submit</button> </div> ); };
We’ve changed the state management mechanism, but now the Input
component’s logic—designed around useState
—no longer works.
Should we now pass the dispatch
function to the Input
component and let it dispatch the specific action? This is exactly where abstraction leaks become a problem.
Abstraction Leak
An abstraction leak occurs when a component knows too much about the internal implementation of another component. In this case, the Input
component assumes:
- The parent component is using
useState
. - The state contains a
name
field directly, alongside other data. - The parent will always maintain the same state structure.
These assumptions make the child component tightly coupled to the parent, so any change in the parent’s state structure or management mechanism requires updates to the child.
Why is that so bad?
- Fragility: Changes to the parent’s logic break the child component, creating a maintenance headache.
- Reduced Reusability: The child is tied to a specific parent implementation, limiting its use in other contexts.
- Loss of Clarity: Passing raw
setState
makes it unclear what the child component is supposed to modify.
How do we solve the leak?
Solving the abstraction leak in this case is extremely simple. The Input
component doesn't need to accept a prop with the actual setter function. It can get a callback function that encapsulates the state change for it. For example, a function named handleNameChange
.
// Form.jsx function Form() { const [formData, setFormData] = useState({ name: '' }); const handleNameChange = (name) => { setFormData((prevState) => ({...prevState, name})); }; return ( <div> <h1>Form</h1> {/* Pass the setter function down to Input */} <Input name={formData.name} onChange={handleNameChange} /> <button onClick={() => console.log(formData)}>Submit</button> </div> ); }; // Input.jsx function Input({ name, onChange }) { const handleInputChange = (event) => { onChange(event.target.value); }; return ( <div> <label> Name: <input type="text" value={name} onChange={handleInputChange} /> </label> </div> ); };
Summary
In this post, we've discussed why passing the setter function of a useState
as a prop is not a good practice. By doing that, we tightly couple the child component to the parent’s implementation and lead to an abstraction leak. Instead, we showed how using an encapsulated callback function improves clarity, reusability, and maintainability.
Don't forget to write down the number of occurrences you found!
Thanks!
Matan.