So I had the opportunity to give a talk at IIIT Raipur
There I addressed common mistakes freshers and experienced devs both make in React.
Since React is most widely used library, these subtle issues can hurt performance, maintainability, or scalability if left unchecked.
Below are some code examples of common React mistakes and their fixes.
1. Using Array Index as Key in Lists vs Unique IDs
I know it has been addressed to you many times, but still its the #1 issue I see in any react repo.
Using the array index as a React list key, this is a lazy shortcut that can break ordering and cause inefficient updates. React’s diff algorithm relies on stable keys; using indexes can confuse the diff, leading to unexpected UI behavior and more re-renders.
In the example below, moving or removing list items would mess up React’s tracking.
// 🚫 Before: Index as key (bad practice)
function BadList() {
const fruits = ['Apple', 'Banana', 'Cherry'];
return (
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li> {/* Index used as key */}
))}
</ul>
);
}
Use a unique identifier for each list item’s key. Here, use an id
property for each fruit. This makes the UI stable on updates, as React can reliably identify items. No more phantom re-renders or out-of-sync list items.
// ✅ After: Unique ID as key (good practice)
function GoodList() {
const fruits = [
{ id: 'a1', name: 'Apple' },
{ id: 'b2', name: 'Banana' },
{ id: 'c3', name: 'Cherry' }
];
return (
<ul>
{fruits.map(fruit => (
<li key={fruit.id}>{fruit.name}</li> {/* Stable unique key */}
))}
</ul>
);
}
2. Deriving State with useEffect
vs Computing on the Fly
This component filters a list based on a search term and mistakenly uses useEffect
+ useState
to store the filtered list. Every time searchTerm
changes, an effect runs to update filteredFruits
state.
This indirection is overkill, it introduces extra state and complexity, and can even cause timing issues (the UI updates after the effect)
// 🚫 Before: Unnecessary effect and state for derived data
function FruitList() {
const [searchTerm, setSearchTerm] = useState('');
const [filteredFruits, setFilteredFruits] = useState(fruits);
useEffect(() => {
// Filter list whenever searchTerm changes
setFilteredFruits(
fruits.filter(fruit =>
fruit.toLowerCase().includes(searchTerm.toLowerCase())
)
);
}, [searchTerm]);
return (
<>
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<ul>
{filteredFruits.map(item => <li key={item}>{item}</li>)}
</ul>
</>
);
}
Instead of this, Calculate the filtered list directly during render, based on the current state. Remove the filteredFruits
state and the effect entirely. Now filteredFruits
is a simple variable recomputed on each render. This is simpler, immediately up-to-date, and less error-prone
// ✅ After: Derive filtered list directly, no extra state
function FruitList() {
const [searchTerm, setSearchTerm] = useState('');
const filteredFruits = fruits.filter(fruit =>
fruit.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<ul>
{filteredFruits.map(item => <li key={item}>{item}</li>)}
</ul>
</>
);
}
3. Unmemoized Callback Causing Re-renders vs useCallback
Memoization
In this parent-child setup, the parent passes an inline callback prop to the child. Each time the parent re-renders (e.g. when count
changes), it creates a new function for handleClick
. Even if the child is wrapped in React.memo
, that new function prop forces the child to re-render every time.
In short, the child can’t take advantage of memoization because the prop reference is unstable:
// 🚫 Before: New handleClick function on every render
const Child = React.memo(({ onClick }) => (
<button onClick={onClick}>Click me</button>
));
function Parent() {
const [count, setCount] = useState(0);
// This function is re-created on every Parent render:
const handleClick = () => {
console.log('Clicked');
};
return (
<div>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Use useCallback
to memoize the handler function. Now handleClick
will stay the same reference between renders (it only re-creates if its dependencies change). With a stable function prop, the memoized child no longer re-renders when the parent’s other state (count
) updates. This prevents needless child renders and improves performance:
// ✅ After: useCallback to preserve function identity
const Child = React.memo(({ onClick }) => (
<button onClick={onClick}>Click me</button>
));
function Parent() {
const [count, setCount] = useState(0);
// Memoize handleClick so it isn't recreated each render:
const handleClick = useCallback(() => {
console.log('Clicked');
}, []); // no dependencies -> same function every time
return (
<div>
<Child onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
I know its confusing, To explain this even more:
In the original, every render made a brand new handleClick
function, so React treated the
’s prop as changed on each render. The child re-rendered constantly (defeating React.memo
and wasting time).
The updated code wraps handleClick
in useCallback
, which caches the function so that React sees the same function instance on each render. As a result, the child component doesn’t re-render unless it actually needs to.
4. Monolithic Component vs Modular Component Architecture
This component tries to do everything at once, it fetches user data and renders the UI. It violates separation of concerns and will become hard to maintain or extend. Before you know it, this file will increase to more than 3000 lines. Completely unmanageable.
In this UserList
example below, the component both loads data in an effect and renders the list. This tightly coupled approach doesn’t scale; the component is doing multiple jobs:
// 🚫 Before: Single component doing data-fetching and UI
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
});
}, []);
return (
<div>
<h1>User List</h1>
<ul>
{users.map(user => (
<UserItem user={user} key={user.id} />
))}
</ul>
</div>
);
}
Always Split the logic into a container component and a presentational component. In the below snippet.
UserListContainer
handles fetching data and state, then renders UserList
(presentational) purely with props. UserList
becomes a simple, stateless UI component. This modular approach is more scalable – each piece has a single responsibility. We avoid deeply nested or bloated components by dividing the work:
// ✅ After: Separate container (data logic) and presentational (UI) components
function UserListContainer() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
});
}, []);
return <UserList users={users} />; // hand off data to UI component
}
function UserList({ users }) {
return (
<div>
<h1>User List</h1>
<ul>
{users.map(user => (
<UserItem user={user} key={user.id} />
))}
</ul>
</div>
);
}
5. Ignoring Errors in Async Calls vs Proper Error Handling
This React effect fetches data but completely ignores errors. If the network request fails or returns an error status, the code does nothing, no catch, no user feedback.
The app could silently break or just never show anything, leaving both developers and users in the dark. For example, below we fetch data and set state, but any failure is uncaught (a rejected promise will likely log an error to console, but our UI/state won’t respond):
// 🚫 Before: No error handling for fetch
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data));
// .catch(...) is missing – errors are ignored
}, []);
Always handle errors from async calls. Here we wrap the fetch in a try/catch (using async/await for clarity). If an error occurs (network failure or non-OK response), we catch it and update an error
state. In a real app, you’d also set a loading state and probably show a message to the user. The key is that errors are not swallowed, we handle them gracefully:
// ✅ After: Handle errors with try/catch and state
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/data');
if (!res.ok) {
throw new Error(`Request failed with ${res.status}`);
}
const data = await res.json();
setData(data);
} catch (err) {
setError(err); // capture the error in state
}
};
fetchData();
}, []);
The original code naively assumes the fetch will always succeed. In reality, network or server errors happen, and failing to handle them is irresponsible. If the request failed, the before code would just do nothing, the user would see a loading spinner forever or an empty UI, and no error message.
That's why, we should at least update the UI (for example, stop a spinner and show an error message, or add a contact support button so customer can report to you) instead of hanging. In short, always handle promise rejections or thrown errors from async calls; your users (and your future self) will thank you when things go wrong.
At the end, I just want to say, none of these mistakes alone will break your app. But together, they quickly pile up technical debt. Fixing these patterns early saves you from future headaches and helps you build a codebase that’s actually worth scaling.
Thanks a lot for reading this far!
Feel free to connect with me on LinkedIn. 😄
A question for you
Have you seen (or made 👀) any of these mistakes before?
Which one cost you the most pain later?
Share your experiences in the comments below!