React는 useState, useEffect, useRef 등 React Hook 객체들을 연결 리스트(Linked List) 구조로 관리한다.
💡 연결 리스트(Linked List)
각 데이터(노드)가 다음 데이터를 가리키는 포인터로 연결된 자료구조이다.
- 기차처럼 각 칸(노드)이 다음 칸을 가리키는 방식으로 연결되어 있으며, 마지막 칸은 null을 가리켜 끝을 표시한다.
- 데이터의 삽입과 삭제가 쉽고 크기를 자유롭게 변경할 수 있다는 장점이 있지만 중간 노드에 접근하려면 첫 노드부터 순회해야 한다는 단점이 있다.
아래 사진과 같은 코드가 있을 때 useState의 Hook 객체 next 속성에 useRef Hook 객체가 연결 되어 있고, useRef Hook 객체의 next 속성에 useEffect Hook 객체가 연결되는 방식이다.
(useState Hook next ➔ useRef Hook next ➔ useEffect Hook)
useEffect가 마지막 Hook이기 때문에 해당 Hook 객체 next 속성에는 아무 값도 들어가지 않아 null이 할당된다.
연결 리스트로 Hook 객체를 관리하는 방식은 크게 두 가지(초기 마운트, 업데이트)로 나누어진다.
이 두 가지 방식을 React ver19.0.0의 내부 소스 코드를 통해 알아보려고 한다.
초기 마운트에는 mountWorkInProgressHook
라는 함수를 통해 훅 객체를 생성하고 연결하는 과정이 이루어진다.
mountWorkInProgressHook
함수 전체 코드 보기
currentlyRenderingFiber
: 현재 렌더링 중인 컴포넌트의 파이버 노드를 의미한다.
workInProgressHook
: 현재 렌더링에서 작업 중인 훅을 가리킨다.
function mountWorkInProgressHook(): Hook {
// 1. 훅 객체 생성
const hook: Hook = {
memoizedState: null, // 가장 최근에 커밋된 업데이트 이후의 상태 값
baseState: null, // 업데이트 큐를 처리하기 전의 초기 상태(함수형 업데이트에도 사용)
baseQueue: null, // 이전 렌더링에서 처리되지 않은 업데이트들의 큐
queue: null, // 상태 업데이트를 위한 디스패치 함수들과 업데이트 정보를 담고 있는 큐
next: null, // 연결 리스트에서 다음 훅을 가리키는 포인터
};
// 2. 훅 연결 리스트 구성
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
1. 훅 객체 생성
2. 훅 연결 리스트 구성
mountWorkInProgressHook
함수가 처음 호출된 경우)currentlyRenderingFiber.memoizedState
에 새로운 Hook을 할당하여 연결 리스트의 시작점으로 설정workInProgressHook
을 새로 추가된 Hook으로 업데이트🤷♂️ 파이버의
memoizedState
에 훅 객체를 저장하는 이유
컴포넌트의 모든 훅들을 연결 리스트 형태로 저장하여 다음 렌더링에서 동일한 순서로 훅을 실행하고 상태를 유지하기 위함이다.
mountWorkInProgressHook
함수가 호출된 경우)workInProgressHook
의 next에 새로운 Hook을 연결workInProgressHook
을 새로 추가된 Hook으로 업데이트💡
mountWorkInProgressHook
함수는 새로운 Hook 객체를 생성하고 이를 연결 리스트 형태로 구성하여 컴포넌트의 Hook들을 관리한다. 이 연결 리스트는 파이버의memoizedState
에 저장되어 렌더링 간에 Hook의 순서와 상태를 보장한다.
업데이트 시에는 updateWorkInProgressHook
함수를 사용하여 Hook 객체를 관리한다.
updateWorkInProgressHook
함수 전체 코드 보기
💡 React는 현재 렌더링이 초기 마운트인지, 업데이트인지 어떻게 구분할까?
컴포넌트를 Hook과 함께 실행시키는renderWithHooks
이라는 함수에서 어떤 Hook을 사용할 것인지 결정한다.ReactSharedInternals.H = // 이전 렌더링 결과가 없거나 이전 렌더링의 상태(훅 객체)가 존재하지 않다면 마운트로 처리 current === null || current.memoizedState === null ? HooksDispatcherOnMount // 초기 마운트 관련 훅(mountState, mountRef 등) : HooksDispatcherOnUpdate; // 업데이트 관련 훅(updateState, updateRef 등)
alternate
: 현재 트리의 파이버 노드가 가리키는 다른 트리(current ↔ workInProgress)의 대응되는 파이버 노드currentHook
: current 트리에서 현재 처리 중인 Hook을 가리키는 포인터nextCurrentHook
: current 트리에서 다음에 처리할 Hook을 가리키는 포인터workInProgressHook
: workInProgress 트리에서 현재 작업 중인 Hook을 가리키는 포인터nextWorkInProgressHook
: workInProgress 트리에서 다음에 처리할 Hook을 가리키는 포인터💡
currentHook
,nextCurrentHook
은 이전 렌더링의 훅 상태를 재사용하기 위해 사용되고,
workInProgressHook
,nextWorkInProgressHook
현재 렌더링의 훅 상태를 관리하기 위해 사용된다.
function updateWorkInProgressHook(): Hook {
// 1. 다음 Hook 포인터 설정
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
// 2. 작업 중인 Hook 확인
let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}
// 3. Hook 재사용 또는 생성
if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
if (nextCurrentHook === null) {
const currentFiber = currentlyRenderingFiber.alternate;
if (currentFiber === null) {
throw new Error(
"초기 렌더에서 업데이트 훅이 호출되었습니다. 이것은 React의 버그일 가능성이 큽니다."
);
} else {
throw new Error("이전 렌더링 중보다 더 많은 훅이 렌더링되었습니다.");
}
}
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
} else {
workInProgressHook = workInProgressHook.next = newHook;
}
}
return workInProgressHook;
}
1. 다음 Hook 포인터 설정(current 트리의 Hook 체인)
첫 번째 Hook을 처리하는 경우(updateWorkInProgressHook
함수가 처음 호출된 경우)
alternate
가 존재한다면 해당 파이버의 memoizedState
에서 시작점을 가져온다.alternate
가 존재하지 않는다면 초기 렌더링임을 의미하므로 null로 초기화 해준다.첫 번째 Hook이 아닌 경우(이전에 updateWorkInProgressHook
함수가 호출된 경우)
2. 작업 중인 Hook 확인(workInProgress 트리의 Hook 체인)
첫 번째 Hook을 처리하는 경우 현재 렌더링 중인 파이버의 memoizedState
에서 시작점을 가져온다.
첫 번째 Hook이 아닌 경우 현재 진행 중인 Hook의 next를 사용한다.
3. Hook 재사용 또는 생성
Hook 재사용
setState
를 호출하여 리렌더링이 발생한 경우 이 경로를 통해 처리새로운 Hook 생성
updateWorkInProgressHook
함수가 호출된 경우 ➔ 에러 발생💡
updateWorkInProgressHook
함수는 이전 렌더링의 Hook 상태를 기반으로 작업 중인 Hook 연결 리스트를 구성하며, 이 과정에서 훅의 순서를 보장하고 상태를 유지한다.
컴포넌트가 처음 렌더링될 때는 mountWorkInProgressHook
함수를 통해 훅을 관리한다.
memoizedState
에 저장컴포넌트가 리렌더링될 때는 updateWorkInProgressHook
함수를 통해 훅을 관리한다.
컴포넌트가 setState
를 호출하는 경우
function Counter() {
const [count, setCount] = useState(0);
// setCount 호출 시 같은 Hook 인스턴스를 재사용
return <button onClick={() => setCount((prev) => prev += 1)}>{count}</button>;
}
부모 컴포넌트의 리렌더링이나 Context 변경 등의 경우
function Parent() {
const [theme, setTheme] = useState('light');
return (
<Child theme={theme} /> // theme이 변경되면 Child는 새로운 Hook 인스턴스 생성
);
}
위에서 알아본 것 처럼 React에서 Hook은 연결 리스트 구조로 관리하기 때문에 호출하는 순서에 의존하여 Hook의 상태를 식별한다.
만약 조건부로 Hook을 호출하면 실행 순서가 변경될 수 있어 React가 올바른 상태를 추적할 수 없기 때문에 조건부 Hook 호출을 제한하여 항상 동일한 순서로 실행되어야 한다는 규칙을 강제한다.
function Component() {
const [count, setCount] = useState(0); // Hook 1
const [age, setAge] = useState(20); // Hook 2
if (count > 0) {
const [name, setName] = useState(""); // Hook 3
}
return (
<button type="button" onClick={() => setCount((prev) => (prev += 1))}>
{count} - 버튼
</button>
);
}
버튼을 클릭하여 카운트를 증가시키면 조건부 Hook 호출 규칙을 위반하였기 때문에 Hook 객체의 숫자가 늘어나 아래와 같은 에러가 발생한다.