-
Notifications
You must be signed in to change notification settings - Fork 2
Description
docs 1
challenges 3번
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const [delay, setDelay] = useState(100);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
// 기존 코드
// const onMount = useEffectEvent(() => {
// return setInterval(() => {
// onTick();
// }, delay);
// });
// useEffect(() => {
// const id = onMount();
// return () => {
// clearInterval(id);
// }
// }, []);
// 변경 코드
useEffect(() => {
const id = () => {
setInterval(() => {
onTick();
}, delay);
}
return () => {
clearInterval(id);
}
}, [delay]);
return (
<>
<h1>
카운터: {count}
<button onClick={() => setCount(0)}>재설정</button>
</h1>
<hr />
<p>
증가량:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
<p>
증가 지연 시간:
<button disabled={delay === 100} onClick={() => {
setDelay(d => d - 100);
}}>–100 ms</button>
<b>{delay} ms</b>
<button onClick={() => {
setDelay(d => d + 100);
}}>+100 ms</button>
</p>
</>
);
}풀이
기존 코드에서는 useEffect(..., [])로 인해 타이머가 마운트 시 한 번만 생성되어 delay 값이 변경되어도 interval 주기가 갱신되지 않는다.
타이머의 지연 시간은 interval 생성 시점에 결정되므로 delay는 반드시 useEffect의 의존성으로 포함되어야 한다.
따라서 delay가 변경될 때마다 기존 interval을 정리하고 새로운 interval을 생성하도록 effect를 구성해야 한다.
useEffectEvent는 interval 내부에서 실행되는 증가 로직을 분리하여, increment 값이 변경되더라도 최신 값을 안전하게 참조하도록 한다.
이 구조를 통해 interval 재생성은 delay 변경 시에만 발생하고, 증가 로직은 불필요한 effect 재실행 없이 항상 최신 상태를 유지할 수 있다.
docs 2
challenges 3번
기존 코드
function handleMove(e) {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
useEffect(() => {
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);useEffect(..., [])로 이벤트 리스너를 마운트 시 1회만 등록한다. 하지만 handleMove는 컴포넌트 바디에서 선언되어 렌더 시점의 canMove를 클로저로 캡처하고 있다.
결과적으로 canMove를 토글해도, 리스너가 참조하는 handleMove는 초기 canMove 값(대개 true/false)을 계속 사용하는 “stale closure” 문제가 생길 수 있다.. 또한 ESLint 경고를 무시(exhaustive-deps disable)하고 있어, 의존성 누락 위험이 존재한다.
나의 코드
function handleMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
useEffect(() => {
if (!canMove) return;
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canMove]);언뜻 보기엔 맞는거 같았으나, 다음과 같은 문제가 발생한다.
canMove를 deps에 넣어 canMove 변경 시 effect가 재실행되도록 했다. canMove가 false면 effect에서 return하여 리스너를 등록하지 않음으로써 “움직임 허용 여부”를 이벤트 등록 자체로 제어했다.
다만 handleMove는 여전히 컴포넌트 바디에 있고 매 렌더마다 새로 만들어지므로, 일반적으로는 deps에 포함되지 않은 함수 참조가 일관되게 관리되지 않을 위험이 있다고 한다.(ESLint disable로 이를 덮고 있음).
또한 리스너 등록/해제를 canMove에 따라 반복하게 되어, 의도는 명확하지만 “등록은 effect가, 동작 조건은 핸들러가” 맡는 전형적 패턴에 비해 구조적 안정성이 떨어질 수 있다고 한다.
정답
useEffect(() => {
function handleMove(e) {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
}, [canMove]);handleMove를 effect 내부에 선언해서, 등록(addEventListener)과 해제(removeEventListener)가 정확히 같은 함수 레퍼런스로 짝을 이루게 된다.
deps에 [canMove]를 두었기 때문에 canMove가 바뀔 때마다 effect가 재실행되고, 그 시점의 canMove를 캡처한 최신 핸들러로 리스너가 갱신된다. 즉, stale closure 문제(이벤트 핸들러나 effect가 “과거 렌더 시점의 state 값”을 기억한 채로 계속 실행되는 문제)를 구조적으로 해결하면서 ESLint 경고를 억지로 무시할 필요가 없다.
리스너 등록은 effect에서, 조건 판단은 핸들러에서라는 책임 분리가 명확하다.