After five and a half years shipping production systems, I've noticed that React performance issues and maintainability problems almost always trace back to the same handful of mistakes. Today I want to walk through the common React anti-patterns I refactor most often—patterns that feel simple in isolation but compound into real pain as your codebase grows.
useEffect to sync derived state
This is probably the anti-pattern I see most. Developers reach for useEffect to keep one piece of state in sync with another, when the answer is almost always to compute the value on render instead.
I used to do this in a trading dashboard I built. We had price data coming in, and I'd calculate the percentage change in an effect:
// ❌ Anti-pattern
const [price, setPrice] = useState(0);
const [percentChange, setPercentChange] = useState(0);
useEffect(() => {
setPercentChange(((price - previousPrice) / previousPrice) * 100);
}, [price, previousPrice]);The problem? If you render before the effect runs, the UI shows stale data. You've also created a second source of truth, which means bugs. React needs to manage two state updates instead of one.
The fix is straightforward—calculate it directly:
// ✅ Correct
const [price, setPrice] = useState(0);
const percentChange = ((price - previousPrice) / previousPrice) * 100;Now the value is always in sync by definition. No effect needed. This applies anywhere you're tempted to update state based on another state value—filtering lists, sorting, formatting, combining multiple fields. If it's purely computational, move it out of state.
The only exception is when you need to preserve old values for animation or comparison. Even then, store the previous value itself, not derived calculations.
Giant components with 15 useState calls
I inherited a CRM form component recently that had 18 separate useState calls for different fields, validation flags, loading states, and error messages. Every field update triggered a re-render of the entire form, and tracing data flow felt like following spaghetti.
When a component needs that many useState declarations, it's almost always a sign that you need to extract smaller pieces or consolidate related state:
// ❌ Anti-pattern (simplified, but you get it)
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [firstNameError, setFirstNameError] = useState('');
const [lastNameError, setLastNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [phoneError, setPhoneError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// ...moreThis is unmaintainable. You'll forget to clear validation state, couple validation logic to UI, and make testing a nightmare.
Consolidate related state into objects, extract sub-components, or reach for useReducer:
// ✅ Better approach 1: Consolidate
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);Or extract sections into separate components:
// ✅ Better approach 2: Extract components
function PersonalInfoSection() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ...
return (...);
}
function ContactSection() {
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
// ...
return (...);
}
export function UserForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<>
<PersonalInfoSection />
<ContactSection />
</>
);
}For even more complex logic, useReducer pairs well with context to avoid prop drilling and keep all state transitions in one place.
Fetching in effects without caching or cancellation
This one gets you in production. I've debugged several memory leak warnings and race conditions that came from uncontrolled data fetching in effects.
The typical pattern looks like this:
// ❌ Anti-pattern
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(r => r.json())
.then(data => setUser(data));
}, [userId]);Problems: If userId changes rapidly (user navigates fast), multiple requests fire and the last one to complete might not match the current userId. You're also fetching the same data repeatedly, burning API quota. And if the component unmounts mid-request, you'll try to setState on an unmounted component.
Here's a production-ready version:
// ✅ Correct
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const fetchUser = async () => {
try {
const res = await fetch(`/api/user/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
if (isMounted) {
setUser(data);
}
} catch (err) {
if (err.name !== 'AbortError' && isMounted) {
setError(err.message);
}
}
};
fetchUser();
return () => {
isMounted = false;
controller.abort();
};
}, [userId]);The AbortController cancels in-flight requests when the effect cleans up. The isMounted flag prevents setState warnings. But honestly, for production apps, reach for a dedicated library like TanStack Query or SWR, which handle deduplication, caching, and background sync for you. I've moved everything I ship to Query—the cognitive load reduction alone is worth it.
Index as key in dynamic lists
In a ride-hailing platform I worked on, we had a list of nearby drivers that updated as they moved. Someone used array index as the key. When a driver left and the array shifted, the UI would match the wrong driver record to the wrong UI element—showing the wrong location, rating, or vehicle type. Users got confused and support tickets piled up.
// ❌ Anti-pattern
{drivers.map((driver, index) => (
<DriverCard key={index} driver={driver} />
))}When the list reorders, filters, or has items inserted/removed, React matches elements by key. With index, it matches by position. So element 0 becomes the new driver even if it's a different person.
Always use a stable, unique identifier:
// ✅ Correct
{drivers.map((driver) => (
<DriverCard key={driver.id} driver={driver} />
))}Even if you don't have IDs, generate them on the server side. If you genuinely have a completely static list that never reorders and only appends, index is technically okay—but why risk it? Every moment I see this in code review, I ask: is this list truly immutable? Usually, it's not.
Common mistakes
- Assuming effects are required for state synchronization. Most derived state can be computed directly. Reach for effects only when you're managing side effects (API calls, subscriptions, timers).
- Not splitting components by responsibility. If a component has more than 5-7 useState calls, ask yourself what's actually happening there and whether smaller components make sense.
- Shipping fetch logic without cancellation. Race conditions and memory leaks are silent killers in production. AbortController is built into modern browsers—use it.
- Using index as key on lists that change. This isn't just bad practice; it's a correctness bug waiting to bite you. Use stable IDs.
Key takeaways
- Compute derived state directly on render instead of syncing it in useEffect.
- Break apart components with too many useState calls—consolidate related state or extract sub-components.
- Cancel fetch requests when effects clean up and guard setState calls with an isMounted flag.
- Always use stable, unique identifiers as keys in dynamic lists, never array indices.
- When in doubt, ask yourself: is this the simplest way to express this behavior? Often, it isn't.






Comments
Loading comments…