Too many re-renders 에러 해결 방법, while문도 없는데 React가 무한 루프라고 한 이유
문제 발생
목록 화면에 "활성 항목만 보기" 필터를 붙이던 날이었습니다. props로 받은 items를 걸러서 state에 넣어 두고 화면에 그리는 구조였습니다.
function Filter({ items }) {
const [visible, setVisible] = useState(items);
setVisible(items.filter((i) => i.active));
return <ul>{visible.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}저장하자마자 화면이 비고 콘솔에 이게 떴습니다.
Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.같은 날 다른 화면에서 카운터 버튼을 고치던 동료도 똑같은 에러를 만났습니다. 그쪽 코드는 이랬습니다.
<button onClick={setCount(count + 1)}>{count}</button>둘 다 "무한 루프"라는데 어디에도 while이나 재귀는 없었습니다. 운영 빌드에서는 메시지조차 달랐습니다. Minified React error #301; visit https://react.dev/errors/301 for the full message라고만 나와서, 에러 수집 도구만 보고는 뭔지 알 수 없었습니다. 이 글의 재현은 React 19.2와 Chromium에서 했습니다.
원인 분석
렌더링 중에 state를 바꾸면 React는 그 렌더를 버리고 다시 그립니다. React 문서의 useState 참고 페이지는 이 에러를 이렇게 설명합니다. 보통 렌더링 중에 조건 없이 state를 설정하고 있다는 뜻이고, 그래서 컴포넌트가 "렌더 → state 설정(렌더 유발) → 렌더 → state 설정…" 하는 루프에 들어간다고요. Filter가 정확히 이 경우입니다. 컴포넌트 함수 본문에서 setVisible을 부르니, 렌더할 때마다 새 배열로 state가 바뀌고, 바뀌었으니 또 렌더합니다. 렌더 횟수를 세어 보니 React는 53번째 렌더에서 포기하고 에러를 던졌습니다. while은 없었지만 컴포넌트 함수 자체가 루프였던 겁니다.
카운터 쪽은 핸들러를 넘긴 게 아니라 호출한 겁니다. 같은 문서가 가장 흔한 원인으로 꼽는 실수입니다. onClick={handleClick()}처럼 쓰면 렌더링 중에 핸들러가 실행되고, 그 안의 setCount가 또 렌더를 유발합니다. onClick={setCount(count + 1)}은 괄호 때문에 "클릭하면 실행할 함수"가 아니라 "지금 당장 실행한 결과"를 onClick에 넣는 코드입니다. 그 결과값은 undefined이고, 실행하는 순간 state가 바뀌어 루프가 됩니다.
운영 빌드의 #301은 같은 에러입니다. React는 운영 빌드에서 에러 문구를 번호로 줄입니다. react.dev/errors/301을 열면 전체 문구가 Too many re-renders. React limits the number of renders to prevent an infinite loop.라고 나옵니다. 에러 수집 도구에 #301만 쌓여 있다면 이 글의 에러입니다.
조건을 걸면 React가 허용하는 패턴이 되기도 합니다. 헷갈리는 지점입니다. 문서에 따르면 렌더링 중에 set 함수를 부르는 건 지금 렌더링 중인 컴포넌트 자신에 대해서만 허용되고, React는 그 렌더의 출력을 버리고 새 state로 즉시 다시 렌더합니다. 그래서 if (tab === "all") setTab("recent")처럼 한 번 바뀌면 더는 조건이 참이 아닌 코드는 에러 없이 두 번 렌더하고 끝납니다. 재현에서도 이 코드는 recent를 그대로 그렸습니다. 루프가 되는 건 "매번 참인 조건"이나 "조건 없는 호출"입니다.
해결 방안
- 이벤트 핸들러에는 함수를 넘깁니다. 문서의 예시 그대로입니다. 핸들러 이름만 넘기거나, 인자가 필요하면 화살표 함수로 감쌉니다.
<button onClick={() => setCount(count + 1)}>{count}</button>onClick={handleClick}과 onClick={handleClick()}은 한 글자 차이라 코드 리뷰에서도 잘 안 보입니다. 에러가 났을 때 JSX 속성에 괄호가 붙은 곳부터 찾으면 빠릅니다. 문서도 원인을 못 찾겠으면 콘솔 에러 옆 화살표를 눌러 스택에서 어느 set 함수 호출이 문제인지 찾으라고 안내합니다.
- props나 다른 state로 계산할 수 있는 값은 state로 두지 않습니다.
Filter의 진짜 문제는visible이 state일 이유가 없다는 점입니다.items에서 언제든 계산할 수 있습니다. 문서는 현재 props나 다른 state로 전부 계산할 수 있는 값이면 그 중복 state를 아예 없애고, 다시 계산하는 비용이 걱정되면useMemo를 쓰라고 합니다.
function Filter({ items }) {
const visible = useMemo(() => items.filter((i) => i.active), [items]);
return <ul>{visible.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}이렇게 바꾸자 set 호출 자체가 사라졌고, 렌더는 한 번으로 끝났습니다. useMemo 없이 그냥 const visible = items.filter(...)로 써도 동작은 같습니다. 목록이 크지 않으면 그게 더 단순합니다.
-
useEffect로 옮기는 건 해결이 아니라 다른 문제입니다. 렌더 중 set 호출을useEffect안으로 넣으면 이 에러는 사라집니다. 하지만 문서의 "Effect가 필요하지 않을 수도 있습니다" 페이지는 이걸 피하라고 합니다. 오래된 값으로 한 번 렌더하고 DOM을 갱신한 뒤 Effect가 돌아 다시 렌더하니, 전체 과정이 한 번 더 반복된다고요. 게다가 의존성에 객체나 배열이 들어가면 이번엔 Effect가 무한히 도는 다른 루프가 됩니다. 그 경우는 예전 글 "의존성 배열을 정직하게 채웠더니 Effect가 무한히 도는 이유"에 정리해 두었습니다. 계산으로 끝낼 수 있으면 2번이 맞고, Effect는 외부 시스템과 동기화할 때만 씁니다. -
prop이 바뀔 때 state를 초기화해야 한다면 "이전 값 비교" 패턴을 씁니다. 정말로 렌더 중에 state를 바꿔야 하는 드문 경우가 있습니다. 목록이 바뀌면 선택을 풀어야 하는 식입니다. 문서가 제시하는 방법은 이전 prop을 state에 저장해 두고 달라졌을 때만 set을 부르는 겁니다.
function List({ items }) {
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}문서는 items !== prevItems 같은 조건이 루프를 피하는 데 필수라고 못 박습니다. 조건이 없으면 1번의 상황으로 돌아갑니다. 그리고 이 패턴도 이해하기 어려우니, 컴포넌트에 다른 key를 줘서 state 전체를 초기화할 수 있는지, 이벤트 핸들러에서 state를 갱신할 수 있는지부터 먼저 확인하라고 덧붙입니다.
- Strict Mode를 켜 두면 렌더 중 부수효과가 일찍 드러납니다. 이 에러는 렌더 함수가 순수하지 않다는 신호이기도 합니다. 문서에 따르면 Strict Mode에서 React는 실수로 들어간 불순한 코드를 찾도록 업데이터 함수를 두 번 호출하는데, 이건 개발 환경에서만 그렇고 운영에는 영향이 없습니다. 렌더 중에 state를 건드리거나 외부 값을 바꾸는 코드가 있으면 개발 중에 두 번 실행되면서 이상한 동작으로 먼저 보입니다. 운영에서
#301을 보고 나서야 알게 되는 것보다는 그쪽이 훨씬 쌉니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.