조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나
Rules of Hooks & fiber 슬롯 순서 매칭
난이도: ★★★☆☆ 연관 노트: 함수 컴포넌트가 옛날 값을 보는 이유
핵심 요약
- React는 훅의 값을 “그 컴포넌트 인스턴스 자신의 저장소(fiber)“에 호출 순서대로 담는다.
useState인지useEffect인지 이름표로 저장하는 게 아니라, **위에서부터 몇 번째로 불렸는지(순서)**로만 매칭한다. - 그래서 훅을
if/for/ earlyreturn뒤에 두면, 어떤 렌더에선 그 훅이 빠져 뒤 순서가 한 칸씩 밀리고, fiber는 “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가 함께 나온다.
→ 확장 개념 (여기서 더 나아가면)
- 함수 컴포넌트가 옛날 값을 보는 이유 — 같은 “매번 새로 실행되는 함수” 전제에서 fiber(유지) vs 클로저(스냅샷) 이야기로 이어진다.
↔ 같은 원리가 적용되는 곳
- 비동기 반복문에서 React 상태가 꼬이는 이유 — 렌더마다 새 스코프가 생긴다는 같은 전제가 stale closure를 만든다.
- Reconciliation (fiber tree) — “컴포넌트 인스턴스 = 트리 상의 위치 + fiber”라는 모델이 조건부 마운트/언마운트 시 state가 유지·초기화되는 규칙까지 설명한다.
참고
- React 공식 — Rules of Hooks — 훅은 최상위에서만 호출
- React 공식 — State as a Snapshot / Queueing — 렌더 = 스냅샷 모델