
리액트에서 useEffect와 setInterval을 함께 쓰다 보면, 분명 1초마다 증가시키라고 했는데 상태가 갱신되지 않거나 0에 멈춰 있는 현상을 자주 만납니다. 원인은 대부분 “stale closure(오래된 클로저)” 입니다. 핵심만 간단히 정리합니다.
[]로 등록한 이펙트는 최초 렌더의 count를 클로저로 캡처합니다. 그 뒤 타이머 콜백은 계속 “초기값”만 봅니다.setState(prev => ...)를 사용하세요. import { useEffect, useState } from 'react'
export default function CounterBroken() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // 초기 렌더의 count(=0)만 본 채 고정
}, 1000)
return () => clearInterval(id)
}, [])
return <div>Count: {count}</div>
}
의존성 배열에 count를 넣지 않으면, 이펙트 안의 콜백은 최초 렌더 시점의 count를 닫아 캡처합니다. 이후 재렌더가 일어나도 콜백이 참조하는 count는 업데이트되지 않습니다.
다음 상태가 이전 상태에 기반하면 함수형 업데이트가 가장 간결하고 안전합니다. 의존성 배열을 []로 두어도 안전합니다.
import { useEffect, useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <div>Count: {count}</div>
}
상태를 의존성에 포함하면, 이펙트가 매 업데이트마다 재등록되며 “최신 상태”를 참조합니다. 단, 매번 타이머가 재생성되니 필요에 따라 setTimeout 패턴이 더 적합할 수 있습니다.
import { useEffect, useState } from 'react'
export default function CounterWithDeps() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setTimeout(() => setCount(count + 1), 1000)
return () => clearTimeout(id)
}, [count])
return <div>Count: {count}</div>
}
setX(prev => ...)는 stale closure 문제를 깔끔히 해결합니다.setInterval/setTimeout은 반드시 cleanup으로 해제합니다.useRef에 타이머 id를 보관하세요.“상태가 이전 값에 의존하면 함수형 업데이트를 쓰고, 타이머는 반드시 정리한다.” 이것만 지키면 useEffect + 타이머의 대부분 문제를 피할 수 있습니다.