언마운트된 컴포넌트에서 setState 호출 경고
왜 이 문제를 만났나?
제 서비스에서 사용자가 페이지를 빠르게 이동할 때 콘솔에 경고가 뜨기 시작했습니다:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
코드는 이랬습니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // 🔥 컴포넌트가 이미 언마운트됐을 수 있음!
});
}, [userId]);
return <div>{user?.name}</div>;
}
사용자가 프로필 페이지에 들어갔다가 빠르게 나가면, API 응답이 오기 전에 컴포넌트가 사라집니다. 그런데 응답이 오면 setUser를 호출하려고 하니까 경고가 뜨는 거였어요.
처음엔 뭐가 이해가 안 갔나?
제가 가진 오개념은 이거였습니다: "useEffect 안의 비동기 작업은 자동으로 취소된다"
하지만 React는 비동기 작업을 자동으로 취소하지 않습니다. 컴포넌트가 언마운트돼도 Promise는 계속 실행됩니다.
타임라인:
- 컴포넌트 마운트 → API 호출 시작
- 사용자가 다른 페이지로 이동 → 컴포넌트 언마운트
- API 응답 도착 →
setUser호출 시도 - 경고: "언마운트된 컴포넌트에서 setState!"
어떤 포인트에서 이해가 됐나?
이 문제를 이해한 건 이런 비유를 들었을 때였습니다:
"컴포넌트는 식당 테이블이고, API 호출은 주문이다. 손님이 나가도 (언마운트) 주방은 계속 요리한다 (Promise 실행). 음식이 나왔을 때 (응답 도착) 테이블이 비어있으면 (언마운트됨) 문제가 생긴다."
해결책은 cleanup 함수로 "손님이 나갔으니 주문 취소"를 알려주는 겁니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // 취소 플래그
fetchUser(userId).then(data => {
if (!cancelled) { // ✅ 취소 안 됐을 때만 setState
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: 취소 플래그 설정
};
}, [userId]);
return <div>{user?.name}</div>;
}
깊이 파고들기
AbortController로 실제 취소
더 나은 방법은 AbortController로 실제로 요청을 취소하는 겁니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch cancelled');
} else {
console.error(err);
}
});
return () => {
controller.abort(); // ✅ 실제로 요청 취소
};
}, [userId]);
return <div>{user?.name}</div>;
}
이제 네트워크 요청도 취소됩니다!
커스텀 훅으로 재사용
이 패턴을 자주 쓰니까 커스텀 훅으로 만들었습니다:
function useSafeState(initialValue) {
const [value, setValue] = useState(initialValue);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const setSafeValue = useCallback((newValue) => {
if (mountedRef.current) {
setValue(newValue);
}
}, []);
return [value, setSafeValue];
}
// 사용
function UserProfile({ userId }) {
const [user, setUser] = useSafeState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // ✅ 안전!
}, [userId]);
return <div>{user?.name}</div>;
}
타이머도 정리해야 함
setTimeout이나 setInterval도 정리해야 합니다:
function Notification({ message }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);
return () => clearTimeout(timer); // ✅ cleanup
}, []);
if (!visible) return null;
return <div>{message}</div>;
}
이벤트 리스너도 정리
이벤트 리스너도 빼먹지 마세요:
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
handleResize(); // 초기값 설정
return () => {
window.removeEventListener('resize', handleResize); // ✅ cleanup
};
}, []);
return <div>{size.width} x {size.height}</div>;
}
내 코드에 어떻게 적용했나?
프로필 페이지 개선
제 프로필 페이지를 이렇게 개선했습니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
실시간 검색
실시간 검색에서도 cleanup을 적용했습니다:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
}, 300); // 디바운싱
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
한 줄 요약
컴포넌트가 언마운트된 후에도 비동기 작업은 계속 실행되므로, useEffect의 cleanup 함수에서 취소 플래그를 설정하거나 AbortController로 요청을 취소해야 메모리 누수 경고를 막을 수 있다.
setState Warning on Unmounted Component
How I Encountered This Problem
When users quickly navigated pages in my service, console warnings started appearing:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
The code was:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // 🔥 Component might already be unmounted!
});
}, [userId]);
return <div>{user?.name}</div>;
}
When users entered the profile page then quickly left, the component disappeared before the API response arrived. But when the response came, it tried to call setUser, triggering the warning.
What Confused Me Initially?
My misconception: "Async operations in useEffect are automatically cancelled"
But React doesn't automatically cancel async operations. Even when components unmount, Promises continue executing.
Timeline:
- Component mounts → API call starts
- User navigates to another page → Component unmounts
- API response arrives → Tries to call
setUser - Warning: "setState on unmounted component!"
The Aha Moment
I understood with this analogy:
"A component is a restaurant table, an API call is an order. Even if the customer leaves (unmount), the kitchen keeps cooking (Promise executes). When food arrives (response) and the table is empty (unmounted), there's a problem."
The solution is a cleanup function to say "customer left, cancel the order."
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // Cancel flag
fetchUser(userId).then(data => {
if (!cancelled) { // ✅ Only setState if not cancelled
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: set cancel flag
};
}, [userId]);
return <div>{user?.name}</div>;
}
Deep Dive
Actually Cancel with AbortController
A better approach is actually cancelling the request with AbortController:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch cancelled');
} else {
console.error(err);
}
});
return () => {
controller.abort(); // ✅ Actually cancel request
};
}, [userId]);
return <div>{user?.name}</div>;
}
Now network requests are cancelled too!
Reusable Custom Hook
Since I use this pattern often, I made a custom hook:
function useSafeState(initialValue) {
const [value, setValue] = useState(initialValue);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const setSafeValue = useCallback((newValue) => {
if (mountedRef.current) {
setValue(newValue);
}
}, []);
return [value, setSafeValue];
}
// Usage
function UserProfile({ userId }) {
const [user, setUser] = useSafeState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // ✅ Safe!
}, [userId]);
return <div>{user?.name}</div>;
}
Clean Up Timers Too
setTimeout and setInterval also need cleanup:
function Notification({ message }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);
return () => clearTimeout(timer); // ✅ cleanup
}, []);
if (!visible) return null;
return <div>{message}</div>;
}
Clean Up Event Listeners
Don't forget event listeners:
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
handleResize(); // Set initial value
return () => {
window.removeEventListener('resize', handleResize); // ✅ cleanup
};
}, []);
return <div>{size.width} x {size.height}</div>;
}
How I Applied This to My Code
Improved Profile Page
I improved my profile page like this:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
Real-time Search
Also applied cleanup to real-time search:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
}, 300); // Debouncing
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
10. Anti-Pattern: The isMounted Ref
You might see this pattern in old tutorials:
/* 🚫 Anti-Pattern: Do not use this */
function Profile() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => { isMounted.current = false };
}, []);
const fetchUser = async () => {
const data = await api.getUser();
if (isMounted.current) { // Check if mounted
setUser(data);
}
};
}
Why is this bad?
It suppresses the memory leak warning but doesn't fix the underlying problem (the Promise is still running and using resources).
Using AbortController not only prevents the state update but actually cancels the network request, saving data and battery.
Always prefer cancellation over just hiding the warning.
11. Modern React: Strict Mode and Double Invocation
In React 18 Strict Mode (Development), useEffect runs twice.
Mount -> Unmount -> Mount.
This is intentional. It forces you to write correct cleanup functions.
If your cleanup logic is missing, you might see two API calls or weird bugs. This "double mount" behavior is React's way of training you to handle unmounts properly.
If you see your API being called twice:
- Don't panic. It's Strict Mode.
- It won't happen in Production.
- But you should implement
AbortControlleror a caching mechanism (like React Query) to handle it gracefully anyway.