Skip to main content

10 Most Common Pitfalls in React.js: How to Avoid Costly Mistakes

Learn the top 10 React.js pitfalls developers face and how to avoid them. From state mutations to memory leaks, master React best practices.

Table of Contents

Introduction

React.js has become the de facto standard for building modern user interfaces, but even experienced developers can fall into common traps that lead to bugs, performance issues, and maintainability problems. Understanding these pitfalls is crucial for writing robust, efficient React applications.

Whether you’re a React beginner or have been working with the library for years, recognizing and avoiding these common mistakes will help you write cleaner code, prevent hard-to-debug issues, and build applications that scale effectively. This guide covers the 10 most frequent pitfalls developers encounter, complete with examples, explanations, and solutions.

We’ll explore issues ranging from state management mistakes to performance anti-patterns, each with practical code examples showing what not to do and how to fix it. By the end, you’ll have a comprehensive understanding of React best practices and how to avoid these costly mistakes in your projects.


1. Mutating State Directly

One of the most fundamental rules in React is that state should be treated as immutable. Directly mutating state objects or arrays can lead to unpredictable behavior, missed re-renders, and bugs that are difficult to track down.

❌ The Problem

function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React", completed: false },
{ id: 2, text: "Build an app", completed: false },
]);
const toggleTodo = (id) => {
// ❌ Directly mutating state - React won't detect this change!
const todo = todos.find((t) => t.id === id);
todo.completed = !todo.completed;
setTodos(todos); // This won't trigger a re-render
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
);
}

✅ The Solution

function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React", completed: false },
{ id: 2, text: "Build an app", completed: false },
]);
const toggleTodo = (id) => {
// ✅ Create a new array with updated objects
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
);
}

🔍 Why This Matters

React uses shallow comparison to determine when to re-render components. When you mutate state directly, the reference to the object or array doesn’t change, so React doesn’t detect the update. This can lead to:

  • UI not updating when state changes
  • Stale closures capturing old state values
  • Difficult-to-debug issues in complex applications

💡 Pro Tips

  • Use the spread operator (...) for objects and arrays
  • For nested updates, consider libraries like Immer for cleaner syntax
  • Always create new references when updating state

2. Missing Dependencies in useEffect

The useEffect hook is powerful but requires careful attention to its dependency array. Missing dependencies can lead to stale closures, infinite loops, or effects that don’t run when they should.

❌ The Problem

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ❌ Missing userId in dependency array
// This effect only runs once, even if userId changes
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, []); // Empty dependency array
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}

✅ The Solution

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId).then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
// ✅ Cleanup function to cancel request if component unmounts
return () => {
cancelled = true;
};
}, [userId]); // ✅ Include all dependencies
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}

⚠️ Common Scenarios

Scenario 1: Using values from props or state

// ❌ Wrong
useEffect(() => {
console.log(count); // count might be stale
}, []);
// ✅ Right
useEffect(() => {
console.log(count);
}, [count]);

Scenario 2: Using functions defined in component

// ❌ Wrong
function Component() {
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + item.price, 0);
};
useEffect(() => {
const total = calculateTotal(items);
setTotal(total);
}, [items]); // Missing calculateTotal dependency
// ...
}
// ✅ Right - Move function inside useEffect or use useCallback
function Component() {
useEffect(() => {
const calculateTotal = (items) => {
return items.reduce((sum, item) => sum + item.price, 0);
};
const total = calculateTotal(items);
setTotal(total);
}, [items]);
// ...
}

🔍 ESLint Rule

Enable react-hooks/exhaustive-deps ESLint rule to catch missing dependencies automatically:

{
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}

3. Creating Objects/Functions in Render

Creating new objects or functions during render causes unnecessary re-renders of child components, even when using React.memo. This is a common performance pitfall.

❌ The Problem

function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
{/* ❌ New object created on every render */}
<Child style={{ color: "blue" }} />
{/* ❌ New function created on every render */}
<ExpensiveChild onClick={() => console.log("clicked")} />
</div>
);
}
const Child = React.memo(({ style }) => {
console.log("Child rendered"); // This logs on every parent render!
return <div style={style}>Child</div>;
});
const ExpensiveChild = React.memo(({ onClick }) => {
console.log("ExpensiveChild rendered"); // This also logs every time!
return <button onClick={onClick}>Click me</button>;
});

✅ The Solution

function Parent() {
const [count, setCount] = useState(0);
// ✅ Memoize styles
const childStyle = useMemo(() => ({ color: "blue" }), []);
// ✅ Memoize callbacks
const handleClick = useCallback(() => {
console.log("clicked");
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child style={childStyle} />
<ExpensiveChild onClick={handleClick} />
</div>
);
}
const Child = React.memo(({ style }) => {
console.log("Child rendered"); // Only logs when style actually changes
return <div style={style}>Child</div>;
});
const ExpensiveChild = React.memo(({ onClick }) => {
console.log("ExpensiveChild rendered"); // Only logs when onClick changes
return <button onClick={onClick}>Click me</button>;
});

💡 When to Use useMemo and useCallback

Use useMemo when:

  • Computing expensive values
  • Passing objects/arrays to memoized children
  • Preventing unnecessary recalculations

Use useCallback when:

  • Passing functions to memoized children
  • Functions are dependencies in other hooks
  • Preventing unnecessary function recreations

Don’t overuse them:

  • Simple primitives don’t need memoization
  • Premature optimization can hurt readability
  • Measure performance before optimizing

🔍 Performance Impact

// ❌ Creates 1000 new objects on every render
function BadList({ items }) {
return (
<ul>
{items.map((item) => (
<MemoizedItem key={item.id} data={{ ...item }} />
))}
</ul>
);
}
// ✅ Pass items directly or memoize the transformation
function GoodList({ items }) {
return (
<ul>
{items.map((item) => (
<MemoizedItem key={item.id} data={item} />
))}
</ul>
);
}

4. Not Using Keys Properly in Lists

Keys help React identify which items have changed, been added, or removed. Using incorrect keys (or missing them) can cause rendering bugs, state issues, and performance problems.

❌ The Problem

function TodoList({ todos }) {
const [selectedId, setSelectedId] = useState(null);
return (
<ul>
{/* ❌ Using index as key - breaks when list order changes */}
{todos.map((todo, index) => (
<li key={index}>
<input
type="checkbox"
checked={selectedId === todo.id}
onChange={() => setSelectedId(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
);
}
// When todos array is reordered:
// - React thinks the first item is still the same component
// - State (like checkbox) gets attached to wrong item
// - Performance degrades as React can't efficiently update

✅ The Solution

function TodoList({ todos }) {
const [selectedId, setSelectedId] = useState(null);
return (
<ul>
{/* ✅ Use stable, unique identifier */}
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={selectedId === todo.id}
onChange={() => setSelectedId(todo.id)}
/>
{todo.text}
</li>
))}
</ul>
);
}

⚠️ Common Key Mistakes

Mistake 1: Using index when items can be reordered

// ❌ Wrong - breaks when sorting/filtering
{
todos.map((todo, index) => <TodoItem key={index} todo={todo} />);
}
// ✅ Right - use stable ID
{
todos.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}

Mistake 2: Using random values

// ❌ Wrong - new key on every render, causes remounts
{
todos.map((todo) => <TodoItem key={Math.random()} todo={todo} />);
}
// ✅ Right - stable, unique key
{
todos.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}

Mistake 3: Missing keys entirely

// ❌ Wrong - React warning, poor performance
{
todos.map((todo) => <TodoItem todo={todo} />);
}
// ✅ Right - always provide keys
{
todos.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}

🔍 When Index is Acceptable

Index can be used as a key when:

  • The list is static (never reordered, filtered, or items removed)
  • Items don’t have stable IDs
  • Performance isn’t critical
// ✅ Acceptable - static list, no reordering
const staticMenuItems = ["Home", "About", "Contact"];
{
staticMenuItems.map((item, index) => <MenuItem key={index} label={item} />);
}

5. Forgetting to Clean Up Effects

Effects that set up subscriptions, timers, or event listeners must clean up after themselves. Failing to do so causes memory leaks and unexpected behavior.

❌ The Problem

function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// ❌ No cleanup - timer continues after unmount
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
}, []);
return <div>Timer: {seconds}s</div>;
}
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// ❌ No cleanup - subscription persists after unmount
const subscription = subscribeToRoom(roomId, (message) => {
setMessages((prev) => [...prev, message]);
});
}, [roomId]);
return <div>{/* messages */}</div>;
}

✅ The Solution

function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
// ✅ Cleanup function clears interval
return () => clearInterval(interval);
}, []);
return <div>Timer: {seconds}s</div>;
}
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const subscription = subscribeToRoom(roomId, (message) => {
setMessages((prev) => [...prev, message]);
});
// ✅ Cleanup function unsubscribes
return () => {
subscription.unsubscribe();
};
}, [roomId]);
return <div>{/* messages */}</div>;
}

🔍 Common Cleanup Scenarios

Scenario 1: Event Listeners

function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
// ✅ Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div>
Window: {size.width}x{size.height}
</div>
);
}

Scenario 2: API Requests

function UserData({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then((data) => {
if (!cancelled) {
setUser(data);
}
});
// ✅ Cancel request if component unmounts or userId changes
return () => {
cancelled = true;
};
}, [userId]);
return <div>{user?.name}</div>;
}

Scenario 3: WebSocket Connections

function LiveUpdates({ channel }) {
const [updates, setUpdates] = useState([]);
useEffect(() => {
const ws = new WebSocket(`ws://api.example.com/${channel}`);
ws.onmessage = (event) => {
setUpdates((prev) => [...prev, JSON.parse(event.data)]);
};
// ✅ Close WebSocket connection on cleanup
return () => {
ws.close();
};
}, [channel]);
return <div>{/* updates */}</div>;
}

⚠️ Memory Leak Warning

Without cleanup, you might see:

  • Multiple timers/subscriptions running simultaneously
  • Event listeners accumulating
  • Network requests completing after component unmounts
  • Browser memory usage increasing over time

6. Incorrect Event Handler Binding

Event handlers in React need proper binding to access component state and props. Modern React with hooks makes this easier, but there are still common mistakes.

❌ The Problem (Class Components)

class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
// ❌ Forgot to bind - 'this' will be undefined
}
handleClick() {
// ❌ 'this' is undefined here
this.setState({ count: this.state.count + 1 });
}
render() {
return (
<button onClick={this.handleClick}>Count: {this.state.count}</button>
);
}
}

✅ Solutions for Class Components

class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
// ✅ Option 1: Bind in constructor
this.handleClick = this.handleClick.bind(this);
}
// ✅ Option 2: Use arrow function (bound to class instance)
handleClick = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<button onClick={this.handleClick}>Count: {this.state.count}</button>
);
}
}

✅ Modern Approach with Hooks

function Counter() {
const [count, setCount] = useState(0);
// ✅ Arrow function automatically has correct closure
const handleClick = () => {
setCount(count + 1);
};
// ✅ Or inline arrow function
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

⚠️ Common Hook Mistakes

Mistake 1: Creating new function on every render

function Parent() {
const [count, setCount] = useState(0);
return (
<div>
{/* ❌ New function created on every render */}
<Child onClick={() => setCount(count + 1)} />
</div>
);
}
// ✅ Use useCallback for memoized children
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount((c) => c + 1); // Use functional update to avoid dependency
}, []);
return <Child onClick={handleClick} />;
}

Mistake 2: Stale closures

function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// ❌ Uses stale count value
setCount(count + 1);
}, 1000);
return () => clearInterval(interval);
}, [count]); // Dependency causes interval to restart
// ✅ Use functional update
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1); // Always uses latest state
}, 1000);
return () => clearInterval(interval);
}, []); // No dependency needed
}

💡 Best Practices

  • Use functional state updates when new state depends on previous state
  • Use useCallback for handlers passed to memoized children
  • Prefer arrow functions in hooks for cleaner syntax
  • Avoid creating functions in JSX when possible

7. Overusing useState When useReducer Fits Better

While useState is perfect for simple state, complex state logic with multiple sub-values or when the next state depends on the previous one often benefits from useReducer.

❌ The Problem

function Form() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
// ❌ Complex state updates scattered throughout
const newErrors = validateForm({ name, email, password });
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
await submitForm({ name, email, password });
setName("");
setEmail("");
setPassword("");
}
setIsSubmitting(false);
};
// Multiple handlers with similar patterns...
const handleNameChange = (e) => {
setName(e.target.value);
setTouched({ ...touched, name: true });
if (errors.name) {
setErrors({ ...errors, name: "" });
}
};
// ... more handlers
}

✅ The Solution with useReducer

const initialState = {
name: "",
email: "",
password: "",
errors: {},
touched: {},
isSubmitting: false,
};
function formReducer(state, action) {
switch (action.type) {
case "SET_FIELD":
return {
...state,
[action.field]: action.value,
touched: { ...state.touched, [action.field]: true },
errors: { ...state.errors, [action.field]: "" },
};
case "SET_ERRORS":
return { ...state, errors: action.errors };
case "SET_SUBMITTING":
return { ...state, isSubmitting: action.value };
case "RESET_FORM":
return initialState;
default:
return state;
}
}
function Form() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({
type: "SET_FIELD",
field,
value: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: "SET_SUBMITTING", value: true });
const errors = validateForm(state);
dispatch({ type: "SET_ERRORS", errors });
if (Object.keys(errors).length === 0) {
await submitForm(state);
dispatch({ type: "RESET_FORM" });
}
dispatch({ type: "SET_SUBMITTING", value: false });
};
return (
<form onSubmit={handleSubmit}>
<input value={state.name} onChange={handleChange("name")} />
{/* ... more fields */}
</form>
);
}

🔍 When to Use useReducer

Use useReducer when:

  • State has multiple sub-values
  • Next state depends on previous state
  • State logic is complex
  • You want to test state transitions separately
  • State updates follow predictable patterns

Use useState when:

  • State is a single primitive value
  • State updates are independent
  • Logic is simple and straightforward

💡 Comparison Example

// ✅ useState - Simple counter
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// ✅ useReducer - Complex counter with history
function AdvancedCounter() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
history: [],
step: 1,
});
return (
<div>
<button onClick={() => dispatch({ type: "INCREMENT" })}>
{state.count}
</button>
<button onClick={() => dispatch({ type: "UNDO" })}>Undo</button>
</div>
);
}

8. Not Memoizing Expensive Computations

Expensive calculations that run on every render can severely impact performance. useMemo helps by caching results until dependencies change.

❌ The Problem

function ProductList({ products, filters }) {
// ❌ Expensive calculation runs on every render
const filteredProducts = products
.filter((product) => {
// Complex filtering logic
return (
filters.category === "all" || product.category === filters.category
);
})
.filter((product) => {
// More complex logic
return (
product.price >= filters.minPrice && product.price <= filters.maxPrice
);
})
.sort((a, b) => {
// Expensive sorting
if (filters.sortBy === "price") return a.price - b.price;
if (filters.sortBy === "name") return a.name.localeCompare(b.name);
return 0;
});
// ❌ Another expensive calculation
const statistics = {
total: filteredProducts.length,
averagePrice:
filteredProducts.reduce((sum, p) => sum + p.price, 0) /
filteredProducts.length,
categories: [...new Set(filteredProducts.map((p) => p.category))],
};
return (
<div>
<Stats data={statistics} />
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

✅ The Solution

function ProductList({ products, filters }) {
// ✅ Memoize expensive filtering/sorting
const filteredProducts = useMemo(() => {
return products
.filter((product) => {
return (
filters.category === "all" || product.category === filters.category
);
})
.filter((product) => {
return (
product.price >= filters.minPrice && product.price <= filters.maxPrice
);
})
.sort((a, b) => {
if (filters.sortBy === "price") return a.price - b.price;
if (filters.sortBy === "name") return a.name.localeCompare(b.name);
return 0;
});
}, [
products,
filters.category,
filters.minPrice,
filters.maxPrice,
filters.sortBy,
]);
// ✅ Memoize derived statistics
const statistics = useMemo(
() => ({
total: filteredProducts.length,
averagePrice:
filteredProducts.reduce((sum, p) => sum + p.price, 0) /
filteredProducts.length,
categories: [...new Set(filteredProducts.map((p) => p.category))],
}),
[filteredProducts],
);
return (
<div>
<Stats data={statistics} />
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

🔍 Performance Measurement

function ExpensiveComponent({ data }) {
const startTime = performance.now();
// ❌ Without memoization
const result = data.map((item) => {
// Expensive operation
return complexCalculation(item);
});
const endTime = performance.now();
console.log(`Render took ${endTime - startTime} milliseconds`);
// ✅ With memoization - only recalculates when data changes
const memoizedResult = useMemo(() => {
return data.map((item) => complexCalculation(item));
}, [data]);
return <div>{/* render result */}</div>;
}

⚠️ When NOT to Use useMemo

Don’t memoize:

  • Simple calculations (addition, string concatenation)
  • Primitives that are cheap to compute
  • Values that change on every render anyway
  • When it hurts readability without performance benefit
// ❌ Unnecessary memoization
const sum = useMemo(() => a + b, [a, b]);
// ✅ Just calculate directly
const sum = a + b;

💡 useMemo vs useCallback

  • useMemo - memoizes values (objects, arrays, computed results)
  • useCallback - memoizes functions
// useMemo - memoize value
const expensiveValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
// useCallback - memoize function
const expensiveFunction = useCallback(() => {
computeExpensiveValue(a, b);
}, [a, b]);

9. Prop Drilling Without Context

Prop drilling occurs when you pass props through multiple component levels that don’t need them, just to reach a deeply nested child. This makes code harder to maintain and refactor.

❌ The Problem

function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
return (
<Layout user={user} theme={theme}>
<Header user={user} theme={theme} />
<MainContent user={user} theme={theme}>
<Sidebar user={user} theme={theme} />
<Article user={user} theme={theme}>
<Comments user={user} theme={theme}>
<CommentForm user={user} theme={theme} />
</Comments>
</Article>
</MainContent>
</Layout>
);
}
// Every component in the chain must accept and pass props
function Layout({ user, theme, children }) {
return <div className={theme}>{children}</div>;
}
function Header({ user, theme }) {
return <header className={theme}>Welcome, {user.name}</header>;
}
// ... many more components passing props they don't use

✅ The Solution with Context

// Create context for user
const UserContext = createContext(null);
// Create context for theme
const ThemeContext = createContext("light");
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<Layout>
<Header />
<MainContent>
<Sidebar />
<Article>
<Comments>
<CommentForm />
</Comments>
</Article>
</MainContent>
</Layout>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// Components can access context directly
function Layout({ children }) {
const theme = useContext(ThemeContext);
return <div className={theme}>{children}</div>;
}
function Header() {
const user = useContext(UserContext);
const theme = useContext(ThemeContext);
return <header className={theme}>Welcome, {user?.name}</header>;
}
function CommentForm() {
const user = useContext(UserContext);
// No need to pass user through 5 component levels!
return <form>{/* form that uses user */}</form>;
}

🔍 Custom Hook Pattern

// Create custom hook for cleaner API
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within UserProvider");
}
return context;
}
function CommentForm() {
const user = useUser(); // Clean, semantic API
return <form>{/* form */}</form>;
}

⚠️ When to Use Context

Use Context for:

  • Theme (light/dark mode)
  • User authentication
  • Language/localization
  • Global UI state (modals, notifications)
  • Data that many components need

Don’t use Context for:

  • Data that changes frequently (causes re-renders)
  • Component-specific state (use local state)
  • Data only needed by direct parent/child (use props)

💡 Context Performance Considerations

// ❌ Bad - All consumers re-render when any value changes
const AppContext = createContext({ user: null, theme: "light", settings: {} });
// ✅ Good - Split contexts by update frequency
const UserContext = createContext(null); // Rarely changes
const ThemeContext = createContext("light"); // Occasionally changes
const SettingsContext = createContext({}); // Frequently changes
// ✅ Or use multiple values with careful memoization
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const value = useMemo(
() => ({
user,
theme,
setUser,
setTheme,
}),
[user, theme],
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

For more advanced React patterns, check out our React Hooks cheatsheet which covers hooks in detail.


10. Ignoring React’s Component Lifecycle

Understanding React’s component lifecycle (or effect lifecycle with hooks) is crucial for proper initialization, updates, and cleanup. Ignoring lifecycle can lead to bugs and performance issues.

🔍 Understanding Lifecycle with Hooks

function Component() {
// 1. Mount phase - runs once
useEffect(() => {
console.log("Component mounted");
// 2. Cleanup on unmount
return () => {
console.log("Component unmounting");
};
}, []);
// 3. Update phase - runs on every render (usually avoid this)
useEffect(() => {
console.log("Component updated");
});
// 4. Conditional update - runs when dependencies change
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Count changed:", count);
}, [count]);
return <div>{count}</div>;
}

❌ Common Lifecycle Mistakes

Mistake 1: Side effects in render

function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// ❌ Side effect in render - runs on every render!
fetchUser(userId).then(setUser);
return <div>{user?.name}</div>;
}
// ✅ Move to useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}

Mistake 2: Not handling unmount

function LiveData() {
const [data, setData] = useState(null);
useEffect(() => {
const subscription = subscribe((data) => {
setData(data);
});
// ❌ No cleanup - subscription continues after unmount
}, []);
return <div>{data}</div>;
}
// ✅ Cleanup on unmount
function LiveData() {
const [data, setData] = useState(null);
useEffect(() => {
const subscription = subscribe((data) => {
setData(data);
});
return () => subscription.unsubscribe();
}, []);
return <div>{data}</div>;
}

Mistake 3: Incorrect dependency arrays

function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(interval);
}, []); // ❌ Missing isRunning dependency
// ✅ Include all dependencies
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(interval);
}, [isRunning]); // ✅ Correct dependencies
}

💡 Lifecycle Best Practices

Mount Phase:

  • Initialize subscriptions, timers, event listeners
  • Fetch initial data
  • Set up one-time configurations

Update Phase:

  • Update state based on prop changes
  • Re-fetch data when dependencies change
  • Update DOM measurements

Unmount Phase:

  • Clean up subscriptions and timers
  • Cancel pending requests
  • Remove event listeners

🔍 Complete Lifecycle Example

function DataVisualization({ dataSource, refreshInterval }) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// Mount: Fetch initial data
useEffect(() => {
let cancelled = false;
fetchData(dataSource)
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err) => {
if (!cancelled) setError(err);
});
return () => {
cancelled = true;
};
}, [dataSource]); // Re-fetch when dataSource changes
// Update: Set up polling when refreshInterval changes
useEffect(() => {
if (!refreshInterval) return;
const interval = setInterval(() => {
fetchData(dataSource).then(setData).catch(setError);
}, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, dataSource]);
// Unmount: Cleanup handled by return functions above
if (error) return <div>Error: {error.message}</div>;
if (!data) return <div>Loading...</div>;
return <div>{/* visualization */}</div>;
}

Conclusion

Avoiding these 10 common React pitfalls will significantly improve your code quality, performance, and developer experience. While React’s API is relatively simple, understanding these nuances separates good React code from great React code.

Key Takeaways

  1. Always treat state as immutable - Use spread operators and create new references
  2. Pay attention to dependency arrays - Missing dependencies cause bugs, extra dependencies cause unnecessary re-renders
  3. Memoize expensive operations - Use useMemo and useCallback strategically
  4. Clean up effects - Prevent memory leaks by cleaning up subscriptions, timers, and listeners
  5. Use proper keys - Stable, unique keys help React efficiently update lists
  6. Choose the right state management - useState for simple state, useReducer for complex logic
  7. Avoid prop drilling - Use Context for data needed by many components
  8. Understand the lifecycle - Know when effects run and when to clean up

Next Steps

  • Practice identifying these patterns in your own codebase
  • Enable ESLint rules like react-hooks/exhaustive-deps to catch issues early
  • Review React’s official documentation for the latest best practices
  • Consider using React DevTools Profiler to identify performance bottlenecks

Additional Resources

Remember, mastering React is a journey. These pitfalls are learning opportunities that will make you a better developer. Keep building, keep learning, and don’t hesitate to refer back to this guide when you encounter these patterns in your projects.