State Structure: Multiple Variables vs. Single Object
Managing state structure in React is an architectural decision: should you create multiple independent useState hooks or group related data into a single object? There is no universal answer; it depends on how your state changes together. This article provides two guiding principles to help you choose the right structure for your specific use case, with practical examples from forms and async operations.
Key Takeaways
- Multiple
useStatehooks are simpler when state variables change independently of each other - Group state into a single object when multiple variables almost always update together
- A single
statusvariable prevents "impossible" states better than separateisLoadingandisErrorbooleans - Grouping state makes it easier to pass data around (e.g., to form APIs or submission handlers)
- Complex update logic is a signal to consider
useReducerinstead ofuseState
Prerequisites
Before reading this article, you should understand:
- The
useStatehook syntax and basic usage - How to update objects immutably using the spread operator (
...) - When state should be local to a component versus shared between components
Two Approaches to State Structure
Consider a simple login form with username and password fields. You can manage these with either approach.
Approach 1: Multiple useState Hooks
Each state variable gets its own hook:
import React, { useState } from 'react';
function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submit:', username, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
Advantages:
- Immediate clarity: each variable is independent and easy to update
- Simple handlers:
setUsername(newValue)is straightforward - No risk of accidentally forgetting to spread state
Disadvantages:
- Becomes verbose with many variables (5+ separate
useStatecalls) - Harder to pass all form data as a single unit to APIs
Approach 2: Single State Object
Group related fields into one object:
import React, { useState } from 'react';
function LoginForm() {
const [formState, setFormState] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState(prevState => ({
...prevState,
[name]: value
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submit:', formState);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
value={formState.username}
onChange={handleChange}
placeholder="Username"
/>
<input
type="password"
name="password"
value={formState.password}
onChange={handleChange}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
export default LoginForm;
Advantages:
- All form data is grouped logically
- Single handler
handleChangeworks for all inputs - Easy to serialize and send to an API:
JSON.stringify(formState) - Resembles class component
this.statepattern
Disadvantages:
- Updates are more verbose; you must spread the previous state
- Risk of accidentally omitting a field during updates
- Requires understanding of immutable state updates
Principle 1: Group State That Changes Together
If you almost always update two or more state variables simultaneously, consider merging them into a single state variable.
Example: Cursor Position
The x and y coordinates are conceptually linked and always change together:
Good: Grouped state
const [position, setPosition] = useState({ x: 0, y: 0 });
function handleMouseMove(e) {
setPosition({
x: e.clientX,
y: e.clientY
});
}
Less ideal: Separate states
const [x, setX] = useState(0);
const [y, setY] = useState(0);
function handleMouseMove(e) {
setX(e.clientX);
setY(e.clientY);
}
While both work, grouping position makes the intent clearer: x and y are facets of a single concept.
Principle 2: Prevent Impossible States
Structure your state so that contradictory combinations are impossible, not just unlikely.
The Contradiction Problem
Imagine a data-fetching component with two boolean flags:
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
This structure allows contradictory states: both isLoading and isError could be true, which has no meaningful interpretation. A bug could cause your UI to display both a spinner and an error message simultaneously.
The Solution: Single Status Variable
Use a single status variable that can only be one value at a time:
const [status, setStatus] = useState('loading'); // 'loading', 'success', or 'error'
// Derive booleans if needed (not stored in state)
const isLoading = status === 'loading';
const isError = status === 'error';
const isSuccess = status === 'success';
Now impossible states are genuinely impossible: the component cannot be both loading and in error simultaneously. You've constrained the state space to only valid combinations.
Complete Example: Settings Form
Here's a realistic settings component showing both approaches side-by-side.
With Multiple States
import React, { useState } from 'react';
function SettingsFormMultiple() {
const [username, setUsername] = useState('guest');
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState(true);
return (
<div>
<label>
Username:
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label>
Theme:
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={notifications}
onChange={(e) => setNotifications(e.target.checked)}
/>
Enable Notifications
</label>
</div>
);
}
export default SettingsFormMultiple;
With Single State Object
import React, { useState } from 'react';
function SettingsFormSingle() {
const [settings, setSettings] = useState({
username: 'guest',
theme: 'light',
notifications: true
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setSettings(prevSettings => ({
...prevSettings,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div>
<label>
Username:
<input
name="username"
value={settings.username}
onChange={handleChange}
/>
</label>
<label>
Theme:
<select
name="theme"
value={settings.theme}
onChange={handleChange}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
<input
name="notifications"
type="checkbox"
checked={settings.notifications}
onChange={handleChange}
/>
Enable Notifications
</label>
</div>
);
}
export default SettingsFormSingle;
Which is better? The single-object version reduces boilerplate (one handler instead of three), but requires more care to spread state correctly. The multiple-states version is more explicit and forgiving.
When to Upgrade to useReducer
If you find yourself updating multiple state variables in response to a single action, or if your update logic is complex, useReducer is a better fit:
// Signal: multiple setState calls in one handler → useReducer
const handleFormSubmit = () => {
setIsLoading(true);
setError(null);
setData(null);
// ...
};
// Better approach: useReducer handles all updates in one dispatch
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'FETCH_START' }); // Updates isLoading, error, data in one action
Frequently Asked Questions
Should I use multiple useState hooks for a form?
It depends on form size. For simple forms (2-3 fields), multiple hooks are fine. For larger forms (5+ fields), a single object is cleaner. Forms that submit all data together are good candidates for single-object state.
Is it wrong to have both isLoading and isError in state?
It's not wrong for simple cases, but it allows contradictory states. If your component logic treats them independently (e.g., different UI branches for each), multiple booleans work. But if they represent mutually exclusive states, use a single status variable instead.
How do I reset a single-object state to its initial value?
Store the initial state in a variable and call the setter with it:
const initialSettings = { username: 'guest', theme: 'light' };
const [settings, setSettings] = useState(initialSettings);
const handleReset = () => setSettings(initialSettings);
What if my state object has deeply nested properties?
Nested objects complicate immutable updates. Keep state as flat as possible. If nesting is unavoidable, use a library like immer to simplify updates, or consider useReducer.
Can I combine multiple useState and useReducer in one component?
Yes. Use useState for simple, independent pieces and useReducer for complex, interdependent logic. Many real components do both.