조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나

Rules of Hooks & fiber 슬롯 순서 매칭

reactrules-of-hooks

난이도: ★★★☆☆ 연관 노트: 함수 컴포넌트가 옛날 값을 보는 이유


핵심 요약

  1. React는 훅의 값을 “그 컴포넌트 인스턴스 자신의 저장소(fiber)“에 호출 순서대로 담는다. useState인지 useEffect인지 이름표로 저장하는 게 아니라, **위에서부터 몇 번째로 불렸는지(순서)**로만 매칭한다.
  2. 그래서 훅을 if / for / early return 에 두면, 어떤 렌더에선 그 훅이 빠져 뒤 순서가 한 칸씩 밀리고, fiber는 “3번째 훅”의 저장값을 엉뚱한 훅에 돌려줘 깨진다.
  3. 반면 {cond ? <Child/> : null}컴포넌트를 조건부 마운트하는 건 안전하다. Child의 훅은 Child 자신의 별도 저장소에 담기므로, 부모의 순서엔 영향이 없고 저장소 세트 하나가 통째로 붙었다 떨어질 뿐이다.

그래서 “조건부로 호출 못 하는 훅”을 게이팅하고 싶으면, 그 훅을 품은 컴포넌트를 조건부로 마운트한다.


왜 헷갈렸나

  • “조건문에 따라 JSX가 달라지는 게 React 원칙 위배”라고 오해했다. → 아니다. 조건부 JSX({cond ? <A/> : <B/>})는 완전히 정상. 위배되는 건 오직 훅을 조건부로 호출하는 것뿐이다.
  • “React가 state를 그냥 순서대로 기억한다”가 막연했다. → state만이 아니라 모든 훅(useEffect의 deps·cleanup, useRef, useMemo/useCallback의 캐시)이 각자 슬롯을 쓴다. 매칭 기준이 이름이 아니라 순서라는 게 핵심.

메커니즘

척추 문장 하나에서 전부 파생된다:

함수 컴포넌트는 매 렌더마다 처음부터 다시 실행되는 함수다.

  • 함수라서 실행이 끝나면 내부 변수를 다 잊는다.
  • 그런데 useState의 값은 렌더를 넘어 유지돼야 한다 → 함수 바깥의 저장소(fiber)가 대신 기억한다.
  • fiber는 훅을 이름으로 저장하지 않고, **호출 순서대로 이어붙인 연결 리스트(linked list)**로 저장한다. → 그래서 매 렌더 훅 순서가 같아야 슬롯이 맞는다.

비유: 매일 새로 출근하는 직원 + 회사 캐비닛

매 렌더 = 새 직원이 출근해 매뉴얼(컴포넌트 함수)을 0번 줄부터 다시 읽음

   ├─ "useState 줄" → 캐비닛(fiber)의 N번 서랍을 연다 (서랍엔 이름표 없음)
   │       매뉴얼 읽는 순서로: 첫 useState=1번 서랍, 둘째=2번 서랍 ...

   └─ 퇴근하며 그날 메모는 폐기. 캐비닛(서랍)은 그대로 남음

조건부 훅이 깨지는 지점

컴포넌트가 useState, useState, useEffect, useState ... 순서라고 하자. fiber 서랍도 이 순서로 저장된다.

[정상]   1:useState  2:useState  3:useEffect  4:useState
             ↓           ↓            ↓            ↓
          서랍1        서랍2        서랍3         서랍4   ✅

[3번 useEffect를 조건부로 건너뜀]
         1:useState  2:useState  3:useState(원래 4번)  ...
             ↓           ↓            ↓
          서랍1        서랍2       서랍3 ← 여기엔 useEffect의 deps가 들어있음!
                                    → useState가 useEffect의 deps 값을 받아옴 💥

fiber는 “이게 useState냐 useEffect냐”가 아니라 **“몇 번째 훅이냐”**만 본다. 순서가 밀리면 값이 통째로 어긋난다. 그래서 훅 개수·순서는 마운트부터 언마운트까지 고정이어야 한다. (fiber는 언마운트 시점에 사라진다.)

조건부 마운트는 왜 안전한가

부모 fiber:  [부모 서랍 1][2][3]   ← 부모 훅. 순서 고정.

Child 켜짐:  부모[1][2][3]  +  ⟦Child 자신의 서랍 [1][2]⟧   ← Child는 별도 세트
Child 꺼짐:  부모[1][2][3]                                  ← 부모 순서 그대로, 세트만 통째로 사라짐

{cond ? <Child/> : null}서랍 세트 하나를 통째로 붙였다 뗐다 하는 것 — 부모 자신의 서랍 순서는 건드리지 않는다. Child가 마운트되면 Child는 자기 훅을 위→아래 일정한 순서로 부른다. 그래서 규칙 위반이 없다.


해결 패턴

// ❌ 훅을 조건부로 호출 — 순서가 밀려 깨짐
function Page() {
  if (!eligible) return <Default />;
  const variation = useExperimentVariation(KEY); // 조건부 뒤의 훅 = 금지
  return variation === "A" ? <A /> : <B />;
}

// ✅ 훅을 품은 컴포넌트를 조건부로 마운트
function Page() {
  // 훅은 항상 최상위에서 무조건 호출
  const eligible = useEligible();

  // 부수효과(노출 발화 등)가 마운트에 묶인 훅이라면,
  // "그 훅을 가진 컴포넌트"를 조건부 렌더해서 게이팅한다.
  return eligible ? <ExperimentGate KEY={KEY} A={<A />} B={<B />} /> : <Default />;
}
// ExperimentGate 내부에서 useExperimentVariation(KEY)가 항상 호출됨
// → eligible일 때만 이 컴포넌트가 마운트 → 그때만 훅이 돈다

다음에 이 상황을 만나면

“이 훅은 특정 조건에서만 돌게 하고 싶다” 싶을 때 → 훅을 if로 감싸지 말고, 훅을 품은 컴포넌트를 조건부 마운트로 분리한다.

  • 훅은 항상 컴포넌트 최상위에서 무조건 호출되는가?
  • 조건이 필요하면 “훅 호출”이 아니라 “컴포넌트 렌더”에 조건을 걸었는가?
  • 마운트에 부수효과(구독·노출·측정)가 묶인 훅이면, 조건부 마운트로 그 부수효과 시점까지 함께 게이팅되는가?

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • Rules of Hooks — “최상위에서만, 항상 같은 순서로 호출”이라는 규칙의 이유가 이 노트 전체다. 규칙을 외우는 게 아니라 fiber 순서 매칭을 이해하면 자동으로 지켜진다.
  • useLayoutEffect가 SSR에서 경고를 내는 이유 — 훅과 commit phase, Rules of Hooks가 함께 나온다.

→ 확장 개념 (여기서 더 나아가면)

↔ 같은 원리가 적용되는 곳

  • 비동기 반복문에서 React 상태가 꼬이는 이유 — 렌더마다 새 스코프가 생긴다는 같은 전제가 stale closure를 만든다.
  • Reconciliation (fiber tree) — “컴포넌트 인스턴스 = 트리 상의 위치 + fiber”라는 모델이 조건부 마운트/언마운트 시 state가 유지·초기화되는 규칙까지 설명한다.

참고