When should you use useEffect in React and when should you avoid it?
I used to put useEffect everywhere in my React components. Every time I needed to compute something, fetch data, or respond to a prop change, I’d reach for it. Then I wondered why my components were re-rendering constantly and my code was so hard to debug.
Here’s what I learned the hard way: useEffect is probably the most misunderstood hook in React. Knowing when NOT to use it is actually a key skill that separates junior from senior developers.
What useEffect Is Actually For
The React team designed useEffect for one specific purpose: synchronizing your component with external systems.
External systems include:
- Browser APIs (IntersectionObserver, ResizeObserver, media queries)
- Third-party libraries (D3 charts, maps, animation libraries)
- Subscriptions (WebSocket connections, Firebase listeners)
- Timers and intervals
The key insight: useEffect runs after render to sync your component with something outside React. If nothing external is involved, you probably don’t need it.
When I Actually Need useEffect
Subscribing to External Data Sources
function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(roomId) connection.connect()
return () => { connection.disconnect() } }, [roomId])
return <div>Connected to {roomId}</div>}This is valid because I’m synchronizing with an external WebSocket connection. The effect connects when roomId changes and cleans up when the component unmounts or roomId changes again.
Browser API Integration
function useIntersectionObserver(ref, options) { const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => { const observer = new IntersectionObserver(([entry]) => { setIsIntersecting(entry.isIntersecting) }, options)
if (ref.current) { observer.observe(ref.current) }
return () => { observer.disconnect() } }, [ref, options])
return isIntersecting}This is valid because IntersectionObserver is an external browser API that needs setup and cleanup.
Document Title Updates
function ProfilePage({ user }) { useEffect(() => { document.title = `${user.name} | My App` return () => { document.title = 'My App' } }, [user.name])
return <div>{user.name}</div>}Valid because I’m synchronizing with the document, which is external to React.
The Anti-Patterns I Used to Commit
Mistake 1: Using useEffect for Derived State
I used to write code like this:
// WRONG: Unnecessary effectfunction Report({ items }) { const [total, setTotal] = useState(0)
useEffect(() => { setTotal(items.reduce((sum, item) => sum + item.price, 0)) }, [items])
return <div>Total: {total}</div>}This causes an extra re-render. The component renders once, then the effect runs, updates state, and triggers another render. I was making React do double the work.
The correct approach is to calculate during render:
// CORRECT: Calculate during renderfunction Report({ items }) { const total = items.reduce((sum, item) => sum + item.price, 0) return <div>Total: {total}</div>}For expensive calculations, I use useMemo:
// CORRECT: Use useMemo for expensive calculationsfunction Report({ items }) { const total = useMemo( () => items.reduce((sum, item) => sum + item.price, 0), [items] ) return <div>Total: {total}</div>}Mistake 2: Resetting State on Prop Changes
I used to reset form state when props changed:
// WRONG: Effect to reset statefunction Form({ userId }) { const [comment, setComment] = useState('')
useEffect(() => { setComment('') }, [userId])
return <textarea value={comment} onChange={e => setComment(e.target.value)} />}The cleaner approach is using the key prop to remount the component:
// CORRECT: Use key to remount componentfunction Form({ userId }) { return <CommentForm key={userId} userId={userId} />}
function CommentForm({ userId }) { const [comment, setComment] = useState('') return <textarea value={comment} onChange={e => setComment(e.target.value)} />}When userId changes, React unmounts the old CommentForm and mounts a fresh one with reset state.
Mistake 3: Data Fetching with useEffect
This was my biggest offender. I wrote verbose data fetching code like this:
// WRONG: Manual data fetching with useEffectfunction UserList() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null)
useEffect(() => { let ignore = false
async function fetchUsers() { try { setLoading(true) const response = await fetch('/api/users') const data = await response.json() if (!ignore) { setUsers(data) } } catch (err) { if (!ignore) { setError(err) } } finally { if (!ignore) { setLoading(false) } } }
fetchUsers()
return () => { ignore = true } }, [])
if (loading) return <Spinner /> if (error) return <Error error={error} /> return <UserGrid users={users} />}That’s 30+ lines of boilerplate. And it doesn’t handle caching, background updates, or request deduplication.
With React Query, it becomes:
import { useQuery } from '@tanstack/react-query'
function UserList() { const { data: users, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(res => res.json()) })
if (isLoading) return <Spinner /> if (error) return <Error error={error} /> return <UserGrid users={users} />}React Query handles caching, deduplication, background refetching, and stale-while-revalidate patterns automatically.
Mistake 4: Responding to User Events in Effects
I used to set a flag in state and watch it with useEffect:
// WRONG: Effect for user actionfunction DeleteButton({ itemId }) { const [shouldDelete, setShouldDelete] = useState(false)
useEffect(() => { if (shouldDelete) { deleteItem(itemId) setShouldDelete(false) } }, [shouldDelete, itemId])
return <button onClick={() => setShouldDelete(true)}>Delete</button>}This is backwards. User events should be handled directly in event handlers:
// CORRECT: Handle in event handlerfunction DeleteButton({ itemId }) { async function handleDelete() { await deleteItem(itemId) }
return <button onClick={handleDelete}>Delete</button>}Mistake 5: Chains of Effects
I once wrote a dashboard with cascading effects:
// WRONG: Effect chain causing re-rendersfunction Dashboard() { const [data, setData] = useState(null) const [filtered, setFiltered] = useState([]) const [sorted, setSorted] = useState([])
useEffect(() => { fetchData().then(setData) }, [])
useEffect(() => { if (data) { setFiltered(data.filter(item => item.active)) } }, [data])
useEffect(() => { if (filtered.length) { setSorted([...filtered].sort((a, b) => b.score - a.score)) } }, [filtered])
return <Chart data={sorted} />}Each effect triggers the next, causing multiple re-renders. The fix is a single derivation chain:
function Dashboard() { const { data } = useQuery({ queryKey: ['dashboard'], queryFn: fetchDashboard })
const processedData = useMemo(() => { if (!data) return []
return data .filter(item => item.active) .sort((a, b) => b.score - a.score) }, [data])
return <Chart data={processedData} />}A Decision Framework
Before using useEffect, I now ask myself:
-
Is there an external system involved? (API, browser API, third-party library)
- Yes ->
useEffectmight be appropriate - No -> Don’t use
useEffect
- Yes ->
-
Am I transforming props or state?
- Yes -> Calculate during render or use
useMemo - No -> Continue
- Yes -> Calculate during render or use
-
Am I fetching data?
- Yes -> Use React Query, SWR, or RTK Query
- No -> Continue
-
Am I responding to a user event?
- Yes -> Use event handler
- No -> Continue
-
Do I need to synchronize with something outside React?
- Yes ->
useEffectis appropriate - No -> Reconsider the approach
- Yes ->
Common useEffect Gotchas
Even when useEffect is the right tool, I’ve made these mistakes:
Missing Dependencies
// WRONG: Missing dependencyfunction Timer({ delay }) { const [count, setCount] = useState(0)
useEffect(() => { const id = setInterval(() => { setCount(c => c + 1) }, delay) return () => clearInterval(id) }, []) // Missing 'delay'
return <div>{count}</div>}The delay prop is used inside the effect but not listed in dependencies. If delay changes, the interval still uses the old value.
No Cleanup
// WRONG: No cleanupfunction ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(roomId) connection.connect() // Memory leak! Connection never closed }, [roomId])
return <div>Chat</div>}Every time roomId changes, a new connection is created but the old one is never closed.
Object Dependencies Without Memoization
// WRONG: Object dependency causes infinite loopfunction Chart({ data }) { useEffect(() => { drawChart(data) }, [data]) // data is new object every render
return <canvas />}If the parent passes a new object on every render, the effect runs on every render. The fix is memoization:
function Chart({ rawData }) { const data = useMemo(() => processData(rawData), [rawData])
useEffect(() => { drawChart(data) }, [data])
return <canvas />}A Real Refactoring Example
Here’s how I refactored a user profile component from manual useEffect data fetching to React Query:
// BEFORE: Manual data fetching with useEffect (40+ lines)function UserProfile({ userId }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null)
useEffect(() => { let ignore = false
async function loadUser() { try { setLoading(true) const response = await fetch(`/api/users/${userId}`) if (!response.ok) throw new Error('Failed to fetch') const data = await response.json() if (!ignore) { setUser(data) setError(null) } } catch (err) { if (!ignore) { setError(err.message) } } finally { if (!ignore) { setLoading(false) } } }
loadUser()
return () => { ignore = true } }, [userId])
if (loading) return <Skeleton /> if (error) return <ErrorMessage error={error} /> return <ProfileCard user={user} />}// AFTER: React Query (15 lines)import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`).then(res => { if (!res.ok) throw new Error('Failed to fetch') return res.json() }), staleTime: 5 * 60 * 1000, })
if (isLoading) return <Skeleton /> if (error) return <ErrorMessage error={error.message} /> return <ProfileCard user={user} />}The React Query version is shorter, handles race conditions automatically, caches data, and refetches in the background when the window regains focus.
Summary
The key principles I now follow:
-
Use
useEffectonly for external system synchronization - browser APIs, third-party libraries, subscriptions. -
Avoid
useEffectfor derived state - calculate during render or useuseMemo. -
Don’t use
useEffectfor data fetching in production - React Query, SWR, or RTK Query provide caching, deduplication, and error handling. -
Handle user events in event handlers - not in effects.
-
Use the
keyprop for component resets - more declarative than resetting state in effects.
The next time I reach for useEffect, I pause and ask: “Am I synchronizing with an external system?” If the answer is no, there’s likely a better approach.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 React Documentation: You Might Not Need an Effect
- 👨💻 TanStack Query Documentation
- 👨💻 Reddit Discussion: What skills helped you become job-ready as a React developer?
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments