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
- 1. Mutating State Directly
- 2. Missing Dependencies in useEffect
- 3. Creating Objects/Functions in Render
- 4. Not Using Keys Properly in Lists
- 5. Forgetting to Clean Up Effects
- 6. Incorrect Event Handler Binding
- 7. Overusing useState When useReducer Fits Better
- 8. Not Memoizing Expensive Computations
- 9. Prop Drilling Without Context
- 10. Ignoring React’s Component Lifecycle
- Conclusion
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
// ❌ WronguseEffect(() => { console.log(count); // count might be stale}, []);
// ✅ RightuseEffect(() => { console.log(count);}, [count]);Scenario 2: Using functions defined in component
// ❌ Wrongfunction 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 useCallbackfunction 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 renderfunction BadList({ items }) { return ( <ul> {items.map((item) => ( <MemoizedItem key={item.id} data={{ ...item }} /> ))} </ul> );}
// ✅ Pass items directly or memoize the transformationfunction 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 reorderingconst 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 childrenfunction 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
useCallbackfor 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 counterfunction Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>;}
// ✅ useReducer - Complex counter with historyfunction 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 memoizationconst sum = useMemo(() => a + b, [a, b]);
// ✅ Just calculate directlyconst sum = a + b;💡 useMemo vs useCallback
useMemo- memoizes values (objects, arrays, computed results)useCallback- memoizes functions
// useMemo - memoize valueconst expensiveValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
// useCallback - memoize functionconst 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 propsfunction 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 userconst UserContext = createContext(null);
// Create context for themeconst 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 directlyfunction 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 APIfunction 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 changesconst AppContext = createContext({ user: null, theme: "light", settings: {} });
// ✅ Good - Split contexts by update frequencyconst UserContext = createContext(null); // Rarely changesconst ThemeContext = createContext("light"); // Occasionally changesconst SettingsContext = createContext({}); // Frequently changes
// ✅ Or use multiple values with careful memoizationfunction 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 useEffectfunction 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 unmountfunction 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
- Always treat state as immutable - Use spread operators and create new references
- Pay attention to dependency arrays - Missing dependencies cause bugs, extra dependencies cause unnecessary re-renders
- Memoize expensive operations - Use
useMemoanduseCallbackstrategically - Clean up effects - Prevent memory leaks by cleaning up subscriptions, timers, and listeners
- Use proper keys - Stable, unique keys help React efficiently update lists
- Choose the right state management -
useStatefor simple state,useReducerfor complex logic - Avoid prop drilling - Use Context for data needed by many components
- 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-depsto catch issues early - Review React’s official documentation for the latest best practices
- Consider using React DevTools Profiler to identify performance bottlenecks
Additional Resources
- React Hooks Documentation - Official React hooks reference
- React Hooks Cheatsheet - Comprehensive hooks reference
- React Performance Optimization - Learn about React’s rendering process
- Common Mistakes in React - React’s guide to common pitfalls
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.