React Hooks: Master useEffect, useCallback & Custom Hooks (Complete Guide)
The Problem You're Solving
Your React component re-renders 100 times per second. It should render once.
// ā Bad: Runs expensive calculations on every render (0.5s each time)
function Dashboard() {
const expensiveData = processLargeDataset(); // Runs 100x!
return <div>{expensiveData}</div>;
}
// ā
Good: Runs only once with useMemo (0.5s total)
function Dashboard() {
const expensiveData = useMemo(() => processLargeDataset(), []);
return <div>{expensiveData}</div>;
}
That difference = 50 seconds wasted on every page load vs 0.5 seconds.
Without understanding hooks, you'll write components that freeze the browser. With them, you squeeze 100x more performance from the same code.
React hooks mastery appears in 32% of front-end interviews and directly impacts app responsiveness.
Core Hooks Review
useState: State Management
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Key points:
count= current statesetCount= function to update stateuseState(0)= initial value is 0- Updates trigger re-render
useEffect: Side Effects
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Fetch when component mounts OR userId changes
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // Dependency array
return user ? <h1>{user.name}</h1> : <p>Loading...</p>;
}
Key points:
- Runs after render completes
- Dependency array controls when it runs
- Missing array = runs every render (ā ļø infinite loops)
- Empty array
[]= runs once (on mount) - Array with values = runs when values change
The Dependency Array: Critical Concept
Problem: Infinite Loops
// ā WRONG - No dependency array
useEffect(() => {
setCount(count + 1); // Runs after render
}); // Runs every render ā infinite loop!
// ā
CORRECT - Empty array (runs once)
useEffect(() => {
console.log('Component mounted');
}, []);
// ā
CORRECT - Specific dependencies
useEffect(() => {
console.log('Count changed to:', count);
}, [count]); // Only runs when count changes
Missing Dependencies (Memory Leaks)
function Timer() {
const [seconds, setSeconds] = useState(0);
const [interval, setInterval] = useState(null);
// ā WRONG - interval not in dependency array
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
setInterval(id);
}, []); // Should include interval? No, setInterval is external
// ā
CORRECT - Proper cleanup
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(id); // Cleanup function
}, []);
}
Rule: Include every external value referenced inside useEffect.
Cleanup Functions (Preventing Memory Leaks)
function ChatConnection({ chatId }) {
useEffect(() => {
// Setup: Connect to chat
const socket = new WebSocket(`wss://chat.example.com/${chatId}`);
socket.onmessage = (event) => {
console.log('Message:', event.data);
};
// Cleanup: Disconnect when component unmounts or chatId changes
return () => {
socket.close();
};
}, [chatId]);
return <div id="messages"></div>;
}
Without cleanup: Old socket connections stay open (memory leak). With cleanup: Connections closed before next effect runs.
useCallback: Memoize Functions
Problem: Unnecessary Renders
function SearchUsers() {
const [results, setResults] = useState([]);
// ā WRONG - New function every render
const handleSearch = (query) => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
};
return (
<>
<SearchInput onSearch={handleSearch} /> {/* New function = re-render */}
<ResultsList results={results} />
</>
);
}
// ā
CORRECT - Same function unless query changes
import { useCallback } from 'react';
function SearchUsers() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('');
const handleSearch = useCallback((q) => {
setQuery(q);
fetch(`/api/search?q=${q}`)
.then(res => res.json())
.then(setResults);
}, []); // Empty array = same function always
return (
<>
<SearchInput onSearch={handleSearch} />
<ResultsList results={results} />
</>
);
}
When useCallback Matters
// ā
GOOD - Function passed to child
<ChildComponent onClick={useCallback(handleClick, [deps])} />
// ā BAD - Function used locally (no benefit)
const handleClick = useCallback(() => {}, []);
console.log(handleClick); // Memoizing has minimal benefit here
Rule: Use useCallback when function is a dependency or prop to optimized child.
useMemo: Memoize Expensive Calculations
import { useMemo } from 'react';
function DataAnalysis({ data }) {
// ā SLOW - Recalculates every render (5 seconds)
const statistics = calculateStatistics(data);
// ā
FAST - Calculates only when data changes
const statistics = useMemo(
() => calculateStatistics(data),
[data]
);
return <StatsDisplay stats={statistics} />;
}
Real Example: Complex Filtering
import { useMemo, useState } from 'react';
function ProductList() {
const [products, setProducts] = useState([...]);
const [filter, setFilter] = useState('');
// ā SLOW - Filters every render (even when filter unchanged)
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
// ā
FAST - Filters only when products or filter changes
const filtered = useMemo(
() => products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
),
[products, filter]
);
return (
<>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<ProductTable products={filtered} />
</>
);
}
Custom Hooks: Reusable Logic
Problem: Code Duplication
// ā Same logic repeated in multiple components
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(1)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return <div>{loading ? 'Loading...' : user?.name}</div>;
}
function UserSettings() {
// ā Same logic again!
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(2)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return <div>{loading ? 'Loading...' : user?.email}</div>;
}
Solution: Custom Hook
// ā
Extract to custom hook
function useFetchUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}
// ā
Use in multiple components
function UserProfile() {
const { user, loading } = useFetchUser(1);
return <div>{loading ? 'Loading...' : user?.name}</div>;
}
function UserSettings() {
const { user, loading } = useFetchUser(2);
return <div>{loading ? 'Loading...' : user?.email}</div>;
}
Advanced Custom Hook: useAsync
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const execute = useCallback(async () => {
setStatus('pending');
try {
const res = await asyncFunction();
setResult(res);
setStatus('success');
} catch (err) {
setError(err);
setStatus('error');
}
}, [asyncFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { status, result, error, execute };
}
// Usage
function UserList() {
const { status, result: users } = useAsync(
() => fetch('/api/users').then(r => r.json())
);
return (
<div>
{status === 'pending' && <p>Loading...</p>}
{status === 'success' && users?.map(u => <p key={u.id}>{u.name}</p>)}
{status === 'error' && <p>Failed to load</p>}
</div>
);
}
Common Mistakes & Pitfalls
ā Mistake 1: Stale Closures
// WRONG - Function captures old value
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
console.log(count); // Always logs 0!
}, 1000);
};
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={handleClick}>Log after 1s</button>
</div>
);
}
// CORRECT - Use functional update
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(c => console.log(c)); // Logs current count
}, 1000);
};
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={handleClick}>Log after 1s</button>
</div>
);
}
ā Mistake 2: useEffect Creating Infinite Loops
// WRONG - No dependency array
useEffect(() => {
const data = fetchData();
setData(data);
}); // Runs every render ā infinite loop
// CORRECT - Add dependency array
useEffect(() => {
const data = fetchData();
setData(data);
}, []); // Runs once
// CORRECT - If fetch depends on param
useEffect(() => {
const data = fetchData(param);
setData(data);
}, [param]);
ā Mistake 3: Missing Cleanup Functions
// WRONG - Memory leak
useEffect(() => {
const id = setInterval(() => {
console.log('Tick');
}, 1000);
}, []); // No cleanup
// CORRECT - Cleanup
useEffect(() => {
const id = setInterval(() => {
console.log('Tick');
}, 1000);
return () => clearInterval(id); // Cleanup
}, []);
Performance Optimization Checklist
// 1. Memoize expensive calculations
const expensiveResult = useMemo(() => {
return complexCalculation(data);
}, [data]);
// 2. Memoize callbacks passed to children
const memoizedCallback = useCallback(() => {
doSomething();
}, [dependencies]);
// 3. Use React.memo for pure components
const PureComponent = React.memo(function Component(props) {
return <div>{props.value}</div>;
});
// 4. Lazy load components
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// 5. Clean up subscriptions
useEffect(() => {
const unsubscribe = subscribe();
return unsubscribe; // Cleanup
}, []);
FAQ: Common Questions & Hook Mistakes
Q1: Do I need to include all dependencies?
A: Yes. Missing dependencies cause bugs.
// ā WRONG - count used but not in dependencies
useEffect(() => {
console.log(count);
}, []); // Missing count!
// ā
CORRECT
useEffect(() => {
console.log(count);
}, [count]);
Q2: When should I use useMemo vs useCallback?
A: useMemo for values. useCallback for functions.
// useMemo - memoize a value
const expensiveValue = useMemo(() => {
return expensiveFunction(data);
}, [data]);
// useCallback - memoize a function
const expensiveFunc = useCallback(() => {
return expensiveFunction(data);
}, [data]);
Q3: Can I use hooks conditionally?
A: No. Always call hooks at top level.
// ā WRONG - Conditional hook
if (shouldFetch) {
useEffect(() => {
fetchData();
}, []);
}
// ā
CORRECT - Conditional logic inside hook
useEffect(() => {
if (shouldFetch) {
fetchData();
}
}, [shouldFetch]);
Q4: Memory leak warning - what causes it?
A: Forgetting to cleanup subscriptions/timers.
// ā WARNS - No cleanup
useEffect(() => {
const id = setInterval(() => {}, 1000);
}, []);
// ā
FIXED - With cleanup
useEffect(() => {
const id = setInterval(() => {}, 1000);
return () => clearInterval(id);
}, []);
Q5: Interview Question: Implement useForm hook.
A: Here's production-ready approach:
function useForm(initialValues, onSubmit) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const handleChange = useCallback((e) => {
const { name, value } = e.target;
setValues(v => ({ ...v, [name]: value }));
}, []);
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(t => ({ ...t, [name]: true }));
}, []);
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
const newErrors = validate(values);
if (Object.keys(newErrors).length === 0) {
await onSubmit(values);
}
setErrors(newErrors);
}, [values, onSubmit]);
return { values, errors, touched, handleChange, handleBlur, handleSubmit };
}
// Usage
function LoginForm() {
const form = useForm(
{ email: '', password: '' },
async (values) => {
await login(values);
}
);
return (
<form onSubmit={form.handleSubmit}>
<input name="email" {...form} />
<input name="password" type="password" {...form} />
<button type="submit">Login</button>
</form>
);
}
Conclusion
Master hooks and you'll write React apps that are:
- Fast - useCallback and useMemo eliminate unnecessary renders
- Clean - Custom hooks eliminate code duplication
- Correct - Understanding dependencies prevents bugs
- Scalable - Proper cleanup prevents memory leaks
The dependency array is your biggest source of bugs. Master it, and you master hooks.