본문으로 건너뛰기
개발 머꼬
개발 노트React
hohyeon.dev3

Rendered more hooks than during the previous render 해결 방법, 조건문 안의 훅이 금지된 이유

  • #Common Pitfall
  • #Engineering Note
  • #React

문제 발생

프로필 화면에 메모 입력칸을 추가하던 중이었습니다. 로그인하지 않은 사용자는 아예 내용을 볼 필요가 없어서, 위쪽에서 먼저 돌려보내고 그 아래에 useState를 하나 더 넣었습니다.

function Profile({ user }) {
  const [tab, setTab] = useState("info");
  if (!user) return <p>로그인이 필요합니다</p>;

  const [memo, setMemo] = useState("");      // 로그인한 경우에만 실행됩니다
  useEffect(() => { document.title = user.name; }, [user]);
  // ...
}

처음 화면은 멀쩡했습니다. 로그인 버튼을 누르는 순간 화면이 하얗게 비고 콘솔에 두 가지가 떴습니다.

Warning: React has detected a change in the order of Hooks called by Profile.
This will lead to bugs and errors if not fixed.

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. undefined                  useState
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Uncaught Error: Rendered more hooks than during the previous render.

반대로 열려 있던 패널을 닫는 코드에서는 같은 원인인데 메시지가 달랐습니다.

Uncaught Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

조건문에 useState를 넣지 말라는 말은 들어봤는데, 왜 안 되는지는 몰랐습니다. 이 글의 재현은 React 19.2와 Chromium에서 했습니다.

원인 분석

React는 훅을 이름이 아니라 "호출 순서"로 구분합니다. React 공식 문서의 state 설명에 "React는 어떤 state를 돌려줄지 어떻게 알까요?" 항목이 있습니다. useState를 부를 때 어떤 state인지 알려 주는 식별자를 넘기지 않는데도 올바른 값이 돌아오는 이유를 이렇게 설명합니다. 훅은 같은 컴포넌트를 렌더할 때마다 안정적인 호출 순서에 의존하며, "훅은 최상위에서만 호출한다"는 규칙을 지키면 항상 같은 순서로 호출된다고요. 내부적으로 React는 컴포넌트마다 state 쌍의 배열을 들고 있고, 렌더 전에 인덱스를 0으로 두었다가 useState가 불릴 때마다 다음 쌍을 주고 인덱스를 하나 올립니다.

즉 React가 아는 건 "memo라는 state"가 아니라 "2번째 훅"입니다. 위 코드는 로그인 전에 훅을 1개, 로그인 후에 3개 부릅니다. 2번째 자리에 전에는 아무것도 없었는데 갑자기 useState가 생기니, React는 순서가 틀어졌다고 판단합니다. 경고에 찍힌 표가 그대로 보여 줍니다. 1번은 양쪽 다 useState인데 2번이 undefined → useState로 바뀌었습니다.

그래서 에러 문구가 두 종류입니다. 훅이 늘어나면 Rendered more hooks than during the previous render., 줄어들면 Rendered fewer hooks than expected. This may be caused by an accidental early return statement.입니다. 뒷문장이 알려 주듯 줄어드는 쪽은 대개 중간의 return 때문입니다. 재현에서도 if (!open) return ... 아래에 있던 useEffect가 건너뛰어지면서 이 메시지가 났습니다. 어느 쪽이든 원인은 같습니다. 렌더마다 훅 개수나 순서가 달라졌다는 것입니다.

문서는 금지 목록을 구체적으로 적어 둡니다. 훅 규칙 문서의 "최상위에서만 훅을 호출하세요" 항목은 반복문, 조건문, 중첩 함수, try/catch/finally 안에서 훅을 부르지 말라고 하면서 이렇게 나열합니다.

  • 조건문이나 반복문 안에서 호출하지 않기
  • 조건부 return 문 이후에 호출하지 않기
  • 이벤트 핸들러 안에서 호출하지 않기
  • 클래스 컴포넌트에서 호출하지 않기
  • useMemo, useReducer, useEffect에 넘기는 함수 안에서 호출하지 않기
  • try/catch/finally 블록 안에서 호출하지 않기

같은 문서의 다른 규칙은 "React 함수에서만 훅을 호출하세요"입니다. 일반 JavaScript 함수에서는 부르면 안 되고, 함수 컴포넌트나 use로 시작하는 커스텀 훅에서만 부를 수 있습니다.

이벤트 핸들러 안에서 부르면 메시지가 또 다릅니다. 버튼 클릭 핸들러 안에 useState를 넣어 재현해 보니 이게 나왔습니다.

Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

세 가지 이유를 다 적어 주지만, 직접 쓴 코드에서 났다면 대개 2번입니다. 1번과 3번은 React 공식 경고 페이지에 따로 설명이 있습니다. 버전이 안 맞거나 React 사본이 두 개인 경우인데, 후자는 npm ls react로 확인해서 두 개 이상 나오면 의존성 트리를 정리해야 합니다. 라이브러리가 react를 peer dependency가 아니라 일반 dependency로 잘못 적어 둔 경우가 흔합니다.

해결 방안

  1. 훅을 전부 위로 올리고, 조건은 훅 안이나 return 쪽으로 옮깁니다. 규칙 문서의 표현 그대로 "이른 반환보다 앞"에 훅을 둡니다.
function Profile({ user }) {
  const [tab, setTab] = useState("info");
  const [memo, setMemo] = useState("");
  useEffect(() => {
    if (user) document.title = user.name;   // 조건을 훅 바깥이 아니라 안쪽으로
  }, [user]);

  if (!user) return <p>로그인이 필요합니다</p>;   // 조건부 반환은 훅 뒤로
  // ...
}

이렇게 고치자 로그인 버튼을 눌러도 에러 없이 화면이 바뀌었습니다. 훅은 언제나 3개가 같은 순서로 불립니다. "로그인 안 했는데 쓸데없이 state를 만든다"는 느낌이 들 수 있지만, useState 하나는 사실상 공짜입니다. 깨지는 쪽이 훨씬 비쌉니다.

  1. 반복문으로 훅을 만들고 있다면 컴포넌트를 쪼갭니다. 항목 개수만큼 useState를 부르는 코드는 항목이 추가되는 순간 같은 에러를 냅니다. 재현에서도 배열에 원소를 하나 추가하자 바로 Rendered more hooks than during the previous render.가 났습니다. 해법은 반복문 안에서 훅을 부르지 않는 겁니다. 항목마다 state가 필요하면 항목 하나를 담당하는 컴포넌트를 만들고 그 안에서 훅을 한 번만 부릅니다.
function Row({ item }) {
  const [value, setValue] = useState(item);   // 컴포넌트마다 훅 1개
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

function List({ items }) {
  return <>{items.map((it) => <Row key={it.id} item={it} />)}</>;
}

Row가 몇 개가 되든 각 Row 안에서는 훅 순서가 변하지 않습니다. React 문서 설명대로 state는 컴포넌트 인스턴스마다 분리되어 있어서, 같은 컴포넌트를 여러 번 렌더해도 각 사본의 state는 서로 독립적입니다.

  1. 조건부로 훅을 쓰고 싶은 상황이면 컴포넌트를 나눕니다. "특정 조건에서만 이 데이터를 구독하고 싶다"는 요구는 흔합니다. 훅을 조건문에 넣는 대신, 그 조건을 부모에서 판단해 컴포넌트 자체를 그릴지 말지로 바꿉니다.
function Parent({ user }) {
  return user ? <LoggedIn user={user} /> : <p>로그인이 필요합니다</p>;
}

LoggedIn이 렌더될 때는 항상 같은 훅을 부르고, 렌더되지 않을 때는 아예 호출 자체가 없습니다. 조건이 컴포넌트 경계로 올라가면 훅 규칙과 충돌하지 않습니다.

  1. try/catch 안에 훅을 넣지 않습니다. 의외로 놓치기 쉬운 금지 항목입니다. 데이터를 불러오다 실패할까 봐 try 안에 훅을 감싸는 코드가 있는데, 문서가 명시적으로 금지합니다. 훅 자체는 바깥에 두고, 예외 처리가 필요한 로직만 훅 안쪽이나 이벤트 핸들러로 옮깁니다. 렌더 중 에러를 화면에서 처리하고 싶다면 try/catch가 아니라 Error Boundary를 씁니다.

  2. 린터를 켜 두면 실행 전에 잡힙니다. 문서도 린터 플러그인이 대부분의 실수를 잡아 준다고 적어 둡니다. eslint-plugin-react-hooks의 rules-of-hooks 규칙이 조건문·반복문 안의 훅과 조건부 return 이후의 훅을 에러로 표시합니다. Create React App이나 Next.js 기본 설정에는 이미 들어 있으니, 에디터에 빨간 줄이 떴는데 무시하고 넘어가지 않는 것만으로도 이 에러의 대부분을 피할 수 있습니다. 반대로 이 에러를 만났는데 린터가 조용하다면, 훅을 부르는 함수 이름이 use로 시작하지 않아 React가 커스텀 훅으로 인식하지 못하는 경우일 수 있습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.