How to Avoid Common React useState Mistakes and Write Cleaner Code
This article explains frequent misuse patterns of React's useState hook—redundant, duplicate, contradictory, and over‑reliance on useEffect—showing why they hurt readability and maintenance, and provides concise refactor solutions using derived state, IDs, and useReducer.
Redundant state
Defining a piece of state that can be derived from another state is a typical beginner mistake. The example component stores firstName, lastName, and a redundant fullName state.
import { useState } from "react";
function RedundantState() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
const onChangeFirstName = (event) => {
setFirstName(event.target.value);
setFullName(`${event.target.value} ${lastName}`);
};
const onChangeLastName = (event) => {
setLastName(event.target.value);
setFullName(`${firstName} ${event.target.value}`);
};
return (
<>
<form>
<input value={firstName} onChange={onChangeFirstName} placeholder="First Name" />
<input value={lastName} onChange={onChangeLastName} placeholder="Last Name" />
</form>
<div>Full name: {fullName}</div>
</>
);
}The fullName value can be computed directly, so the fix removes the extra state:
export function RedundantState() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = `${firstName} ${lastName}`;
...
}Duplicate state
Storing a full object that already exists in another source violates the single‑source‑of‑truth principle. The example keeps the selected list item in its own state.
function DuplicateState({ items }) {
const [selectedItem, setSelectedItem] = useState();
const onClickItem = (item) => setSelectedItem(item);
return (
<>
{selectedItem && <Modal item={selectedItem} />}
<ul>
{items.map(row => (
<li key={row.id}>
{row.text}
<button onClick={() => onClickItem(row)}>Open</button>
</li>
))}
</ul>
</>
);
}The solution stores only the item's ID and derives the object when needed:
function DuplicateState({ items }) {
const [selectedItemId, setSelectedItemId] = useState();
const selectedItem = items.find(({ id }) => id === selectedItemId);
const onClickItem = (itemId) => setSelectedItemId(itemId);
return (
<>
{selectedItem && <Modal item={selectedItem} />}
<ul>
{items.map(row => (
<li key={row.id}>
{row.text}
<button onClick={() => onClickItem(row.id)}>Open</button>
</li>
))}
</ul>
</>
);
}Using useEffect to update state
Synchronising one piece of state with another via useEffect adds indirection and extra renders. The same list component can be rewritten without the effect by storing only the ID, as shown above.
Using useEffect to listen to state changes
In a product‑detail view, useEffect tracks visibility changes to fire an analytics event.
function ProductView({ name, details }) {
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
useEffect(() => {
trackEvent({ event: "Toggle Product Details", value: isDetailsVisible });
}, [isDetailsVisible]);
const toggleDetails = () => setIsDetailsVisible(!isDetailsVisible);
return (
<div>
{name}
<button onClick={toggleDetails}>Show details</button>
{isDetailsVisible && <ProductDetails {...details} />}
</div>
);
}Moving the tracking call into the toggle function simplifies the component:
function ProductView({ name, details }) {
const [isDetailsVisible, setIsDetailsVisible] = useState(false);
const toggleDetails = () => {
setIsDetailsVisible(!isDetailsVisible);
trackEvent({ event: "Toggle Product Details", value: !isDetailsVisible });
};
return (
<div>
{name}
<button onClick={toggleDetails}>Show details</button>
{isDetailsVisible && <ProductDetails {...details} />}
</div>
);
}Contradictory state
Multiple inter‑dependent useState variables can become inconsistent (e.g., loading, error, and data flags). Using useReducer centralises state transitions.
const initialState = { data: [], error: null, isLoading: false };
function reducer(state, action) {
switch (action.type) {
case "FETCH":
return { ...state, error: null, isLoading: true };
case "SUCCESS":
return { ...state, error: null, isLoading: false, data: action.data };
case "ERROR":
return { ...state, isLoading: false, error: action.error };
default:
throw new Error(`action "${action.type}" not implemented`);
}
}
export function NonContradictingState() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({ type: "FETCH" });
fetchData()
.then(data => dispatch({ type: "SUCCESS", data }))
.catch(error => dispatch({ type: "ERROR", error }));
}, []);
...
}Deeply nested state
Updating deeply nested objects requires immutable copies, which quickly becomes unwieldy. Flattening the data and referencing children by IDs simplifies updates.
function FlatCommentsRoot() {
const [comments, setComments] = useState([
{ id: "1", text: "Comment 1", children: ["11", "12"] },
{ id: "11", text: "Comment 1 1" },
{ id: "12", text: "Comment 1 2" },
{ id: "2", text: "Comment 2" },
{ id: "3", text: "Comment 3", children: ["31"] },
{ id: "31", text: "Comment 3 1", children: ["311"] },
{ id: "311", text: "Comment 3 1 1" }
]);
const updateComment = (id, text) => {
const updated = comments.map(comment =>
comment.id !== id ? comment : { ...comment, text }
);
setComments(updated);
};
...
}How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
