컴포넌트에 state 변수를 추가할 수 있는 React Hook이다.
내부 동작 원리를 파악하는 소스 코드는 React v19.0.0 버전의 코드입니다.
// mountState의 내부 구현
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook(); // 새로운 훅 객체 생성
if (typeof initialState === "function") {
// 초기 값이 함수인 경우
// 초기 렌더링 시에만 이 함수가 호출되어 불필요한 계산을 방지한다.
const initialStateInitializer = initialState;
initialState = initialStateInitializer();
}
// 초기 상태 값을 훅의 memoizedState와 baseState에 저장
hook.memoizedState = hook.baseState = initialState;
// 상태 업데이트를 관리할 큐 객체 생성
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null, // 대기 중인 업데이트
lanes: NoLanes, // 우선 순위
dispatch: null, // 디스패치 함수
lastRenderedReducer: basicStateReducer, // 마지막으로 렌더링된 리듀서
lastRenderedState: (initialState: any), // 마지막으로 렌더링된 상태
};
hook.queue = queue;
return hook; // 훅 객체 반환
}
// useState의 초기 렌더링(useState의 실제 인터페이스)
function mountState<S>(
initialState: (() => S) | S // 초기 값
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountStateImpl(initialState); // 훅 객체 생성
const queue = hook.queue;
// dispatchSetState 함수 생성(상태 업데이트 트리거)
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
null,
currentlyRenderingFiber, // 현재 렌더링 중인 파이버
queue // 상태 업데이트를 관리하는 큐 객체
): any);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch]; // 훅의 현재 상태와 디스패치 함수 반환
}
memoizedState와 baseState의 역할
업데이트 큐 객체 설명
// 상태 업데이트 큐 객체는 React 상태 관리 시스템에서 중요한 역할을 한다.
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
useState
의 두 번째 반환 값으로 상태 업데이트를 트리거 할 수 있다)function dispatchSetStateInternal<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
lane: Lane
): boolean {
// 업데이트 객체 생성
const update: Update<S, A> = {
lane,
revertLane: NoLane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
// 현재 렌더링 중이라면
// 렌더링 중에 업데이트가 발생하는 경우 업데이트 큐에 값을 넣는다.
enqueueRenderPhaseUpdate(queue, update);
} else {
// 렌더링 중이 아니라면
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 현재 파이버와 alternate 파이버에 진행 중인 업데이트가 없다면
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
// 마지막으로 렌더링된 리듀서를 사용해 새로운 상태를 즉시 계산
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 새로운 상태가 현재 상태와 같다면 업데이트를 큐에 추가
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 리렌더링이 필요하지 않음
}
} catch (error) {}
}
}
// 업데이트를 큐에 추가
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
// 파이버 트리의 업데이트 스케줄링
// 트랜지션 관련 업데이트 처리
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true; // 리렌더링이 필요함
}
}
return false; // 리렌더링이 필요하지 않음
}
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A
): void {
const lane = requestUpdateLane(fiber); // 현재 업데이트의 우선순위 레인 요청
// 실제 상태 업데이트 로직 수행
const didScheduleUpdate = dispatchSetStateInternal(
fiber,
queue,
action,
lane
);
}
렌더링 중에 상태 업데이트가 발생하거나, 새로운 상태 값이 현재 상태와 같다면 업데이트를 큐에 추가하지만 불필요한 리렌더링을 스케줄링하지 않아 성능을 향상시킨다.
위 상황들에 포함되지 않고 업데이트를 큐에 성공적으로 추가한다면 리렌더링을 스케줄링한다.
function updateState<S>(
initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
// basicStateReducer: 새로운 상태를 반환하는 기본 리듀서
return updateReducer(basicStateReducer, initialState);
}
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: (I) => S
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook(); // 현재 작업중인 훅 가져오기
return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}
function updateReducerImpl<S, A>(
hook: Hook, // 현재 훅
current: Hook, // 이전 훅
reducer: (S, A) => S // 리듀서 함수
): [S, Dispatch<A>] {
// 훅에 큐가 없으면 에러 발생(조건부 훅 호출 방지)
const queue = hook.queue;
if (queue === null) {
throw new Error(
"Should have a queue. You are likely calling Hooks conditionally, " +
"which is not allowed. (https://react.dev/link/invalid-hook-call)"
);
}
// 현재 리듀서 함수를 lastRenderedReducer로 설정
queue.lastRenderedReducer = reducer;
let baseQueue = hook.baseQueue;
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// 대기 중인 업데이트가 있으면 기존 baseQueue와 병합
if (baseQueue !== null) {
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
const baseState = hook.baseState; // 이전 렌더링의 기본 상태
if (baseQueue === null) {
// baseQueue가 없으면 baseState 사용
hook.memoizedState = baseState;
} else {
// baseQueue가 있다면 업데이트 처리를 위한 변수 초기화
const first = baseQueue.next;
let newState = baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast: Update<S, A> | null = null;
let update = first;
let didReadFromEntangledAsyncAction = false;
do {
// 각 업데이트의 우선순위 확인
const updateLane = removeLanes(update.lane, OffscreenLane);
const isHiddenUpdate = updateLane !== update.lane;
// 오프 스크린 업데이트인지 현재 렌더링에서 건너뛰어야 하는지 결정
const shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// 현재 렌더링에서 이 업데이트를 스킵해야하는 경우
const clone: Update<S, A> = {
lane: updateLane,
revertLane: update.revertLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
// 업데이트의 복사본을 새로운 기본 큐에 추가
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// 현재 렌더링 중인 파이버의 레인을 업데이트
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane
);
markSkippedUpdateLanes(updateLane);
} else {
const revertLane = update.revertLane;
if (!enableAsyncActions || revertLane === NoLane) {
// 비동기 액션이 확성화되거나 revertLane이 없는 경우
if (newBaseQueueLast !== null) {
// 적용할 업데이트를 새로운 기본 큐에 추가
const clone: Update<S, A> = {
lane: NoLane,
revertLane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
if (updateLane === peekEntangledActionLane()) {
// 비동기 액션과 관련된 업데이트인 경우
didReadFromEntangledAsyncAction = true;
}
} else {
// 비동기 액션 관련 업데이트 처리
if (isSubsetOfLanes(renderLanes, revertLane)) {
update = update.next;
if (revertLane === peekEntangledActionLane()) {
didReadFromEntangledAsyncAction = true;
}
continue;
} else {
const clone: Update<S, A> = {
lane: NoLane,
revertLane: update.revertLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
revertLane
);
markSkippedUpdateLanes(revertLane);
}
}
const action = update.action;
if (shouldDoubleInvokeUserFnsInHooksDEV) {
reducer(newState, action);
}
if (update.hasEagerState) {
// 이미 계산된 상태가 있다면 계산된 상태를 사용
newState = ((update.eagerState: any): S);
} else {
// 없다면 새로운 상태 계산
newState = reducer(newState, action);
}
}
update = update.next; // 다읍 업데이트로 이동
// 모든 업데이트가 처리될 때까지 반복
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
// 새로운 기본 상태 설정
newBaseState = newState;
} else {
// 기본 큐 설정
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
if (!is(newState, hook.memoizedState)) {
// 상태가 변경 되었다면 업데이트 마킹
markWorkInProgressReceivedUpdate();
if (didReadFromEntangledAsyncAction) {
// 비동기 액션이 있다면 처리
const entangledActionThenable = peekEntangledActionThenable();
if (entangledActionThenable !== null) {
throw entangledActionThenable;
}
}
}
// 훅 상태 업데이트
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
if (baseQueue === null) {
// 모든 업데이트가 처리되면 큐의 레인 초기화
queue.lanes = NoLanes;
}
// 훅에 memoizedState와 디스패치 함수를 반환
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
1. 초기 설정
훅의 업데이트 큐를 확인하고 큐가 없으면 에러를 발생시킨다.(훅 조건부 호출 방지)
현재 리듀서를 큐의 lastRenderedReducer
로 설정한다.
➔ 리듀서: 현재 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수이다.
각 업데이트(액션)를 현재 상태에 적용하여 새로운 상태를 계산하는 역할을 한다.
2. 대기 중인 업데이트 처리
baseQueue
와 pendingQueue
를 확인하고 대기중인 업데이트(pendingQueue
)가 있다면 baseQueue
와 병합한다.
➔ 렌더링 도중 새로운 업데이트가 발생할 수 있는데, 이러한 업데이트들(pendingQueue
)을 기존의 처리되지 않은 업데이트들(baseQueue)
과 병합함으로써 모든 업데이트가 순서대로 처리될 수 있도록 보장한다.
3. 기본 상태 설정
baseQueue
가 없으면 baseState
를 그대로 사용한다.
baseQueue
가 있으면 업데이트 처리 과정을 시작한다.
➔ baseQueue
가 없는 경우: 이전 렌더링에서 모든 업데이트가 처리되었음을 의미한다.
따라서 단순히 baseState
를 현재 상태로 사용한다.
➔ baseQueue
가 있는 경우: 이전 렌더링에서 처리되지 않은 업데이트가 있음을 의미한다.
이 경우 업데이트들을 현재 렌더링에서 처리해야 하므로 업데이트 처리 과정을 시작한다.
4. 업데이트 순회 및 처리
각 업데이트의 우선순위를 확인한다.
업데이트를 건너뛸지, 처리할지 결정한다.
건너뛰는 업데이트는 새로운 기본 큐에 추가한다.
처리하는 업데이트는 리듀서를 적용하여 새로운 상태를 계산한다.
➔ 건너뛰는 업데이트: 현재 렌더링의 우선순위보다 낮은 우선순위를 가진 업데이트를 말한다.
이러한 업데이트는 현재 렌더링에서 처리되지 않고 다음 렌더링으로 미뤄진다.
5. 비동기 액션 처리
얽혀있는(entangled) 비동기 액션과 관련된 업데이트를 확인하고 처리한다.
➔ 얽혀있는(entangled) 비동기 액션: React의 동시성 모드에서 사용되는 개념으로 비동기 작업의 결과가 현재 렌더링 과정과 얽혀있는(entangled) 상황을 나타낸다.
➔ 이러한 액션이 확인되면 React는 현재 비동기 작업의 결과를 기다리거나, 필요한 경우 현재 렌더링을 중단하고 다시 시작할 수 있다.
6. 새로운 상태 설정
memoizedState
로 설정한다.baseState
와 baseQueue
를 설정한다.7. 상태 변경 확인
8. 레인 초기화
9. 결과 반환
초기 렌더링 시, useState는 새로운 훅 객체를 생성하고 초기 상태를 설정한다. 이 과정에서 상태 업데이트를 관리할 큐 객체도 함께 생성된다.
상태 업데이트 함수가 호출되면 React는 업데이트 객체를 생성하고 현재 렌더링 상태를 확인한다. 만약 렌더링 중이 아니고 현재 진행 중인 업데이트가 없다면 새로운 상태를 즉시 계산한다.
리렌더링 과정에서 React는 이전에 대기 중이던 모든 업데이트를 처리한다. 각 업데이트는 우선순위에 따라 처리되거나 다음 렌더링으로 미뤄진다.
모든 업데이트가 처리된 후 최종 상태가 계산되어 컴포넌트가 새로운 상태로 리렌더링된다.
useState 훅은 Fiber 노드에 연결되어 있으며 이를 통해 React는 컴포넌트의 상태를 효율적으로 추적하고 업데이트할 수 있다.
Fiber 아키텍쳐는 작업을 작은 단위로 나눠 처리할 수 있어 브라우저의 메인 스레드를 차단하지 않고 렌더링을 수행할 수 있다.
아래 코드에서 버튼을 3번 클릭하면 App 컴포넌트와 A 컴포넌트는 각각 몇번 렌더링 할까?
function A() {
console.log("A 컴포넌트 렌더링");
return null;
}
function App() {
const [state, setState] = useState(false);
const counter = useRef(0);
console.log(`현재 state: ${state}`);
console.log("App 컴포넌트 렌더링");
return (
<div>
<button
onClick={() => {
console.log(`-------\n버튼 ${++counter.current}회 클릭\n-------`);
setState(true);
}}
>
클릭
</button>
<A />
</div>
);
}
결과는 App 컴포넌트는 2번, A 컴포넌트는 1번 리렌더링이 발생한다.
첫 번째 버튼 클릭
두 번째 버튼 클릭
세 번째 버튼 클릭
여기서 드는 2가지 의문이 있다.
두 번째 버튼 클릭할 때, 상태 값 true
에서 true
로 업데이트하여 상태 변화가 없는데 왜 App 컴포넌트는 리렌더링이 발생할까?
App 컴포넌트에서 리렌더링이 발생했을 때(두 번째 버튼 클릭) 왜 자식 컴포넌트인 A 컴포넌트는 리렌더링이 발생하지 않을까?
💡 핵심 포인트
- 초기 최적화 시도:
dispatchSetState
함수에서 불필요한 리렌더링을 피하려고 시도한다.- Fiber 노드의 상태: Fiber 노드의 lanes라는 속성을 사용해 업데이트가 필요한지 판단한다.
- 현재 Fiber와 대체 Fiber: 현재 Fiber와 대체(alternate) Fiber 두 버전을 관리한다.(더블 버퍼링)
- 업데이트 큐잉: 상태 업데이트 시 현재 Fiber에 먼저 업데이트를 표시하고, 리렌더링 과정에서 대체 Fiber(workInProgress)에도 업데이트가 적용된다.
- 리렌더링 과정: 실제 리렌더링은
beginWork
함수에서 시작되며 이 과정에서 lanes가 초기화된다.
첫 번째 버튼 클릭
setState(true)
가 호출되면 React는 현재 Fiber 노드에 업데이트를 표시한다.beginWork
함수에서 lanes가 초기화된다. false
➔ true
로 변경되고 App 컴포넌트, A 컴포넌트 모두 리렌더링된다.두 번째 버튼 클릭
setState(true)
가 호출된다.// 파이버에 업데이트 표시가 있어 NoLanes에 해당하지 않는다.
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)){
// 이 조건에 해당하지 않는 경우, 즉시 상태 확인을 건너뛰고 else문 코드를 실행한다.
} else {
// 두 번째 버튼 클릭 시에는 여기 코드가 실행
// 업데이트를 큐에 추가
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
// 파이버 트리의 업데이트 스케줄링
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true; // 리렌더링이 필요함
}
}
세 번째 버튼 클릭
setState(true)
가 호출 시 두 Fiber 모두 lanes가 NoLanes인 상태이다.dispatchSetStateInternal
함수에서 새로운 상태를 즉시 계산하고 현재 상태와 비교한다.if (is(eagerState, currentState)) {
// 새로운 계산한 상태(eagerState)와 현재 상태(currentState) 비교
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 리렌더링이 필요하지 않음
}
📝 정리하기
- 첫 번째 클릭에서는 상태가 실제로 변경되어 전체 리렌더링이 발생한다.
- 두 번째 클릭에서는 Fiber 노드에 업데이트 표시가 남아있어 App 컴포넌트의 리렌더링이 발생하지만, 자식 컴포넌트는 최적화된다.(아래에서 자세히 설명)
- 세 번째 클릭에서는 모든 Fiber 노드가 깨끗한 상태가 되어 완전한 최적화(bail out)가 이루어져 리렌더링이 발생하지 않는다.
첫 번째 버튼 클릭
두 번째 버튼 클릭
세 번째 클릭
// 불필요한 렌더링을 방지하는 중요한 최적화단계이다.
// 컴포넌트와 그 자식들의 상태를 효율적으로 관리하여 변경이 필요한 부분만 업데이트하도록 한다.
function bailoutOnAlreadyFinishedWork(
current: Fiber | null, // 현재 렌더링된 Fiber 노드
workInProgress: Fiber, // 작업 중인 Fiber 노드
renderLanes: Lanes // 현재 렌더링 중인 우선순위
): Fiber | null {
if (current !== null) {
// 현재 Fiber가 존재하면 이전 렌더링의 의존성을 새로 작업 중인 Fiber에 복사
// 이는 불필요한 재계산을 방지한다.
workInProgress.dependencies = current.dependencies;
}
// 현재 업데이트 레인 스킵 표시(이 업데이트가 처리되지 않았음을 표시)
markSkippedUpdateLanes(workInProgress.lanes);
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 현재 렌더링 레인과 자식 컴포넌트의 레인 비교, 겹치는 부분(자식에게 작업이 없다면)이 없으면 최적화 진행
if (enableLazyContextPropagation && current !== null) {
// 지연 컨텍스트 전파가 활성화되어 있고 현재 Fiber가 존재하는 경우
// 부모 컨텍스트 변경을 지연 전파
lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 다시 자식 레인을 확인하고 여전히 작업이 없으면 null을 반환하여 렌더링을 건너뛴다.
return null;
}
} else {
// 지연 컨텍스트 전파가 비활성화되어 있다면 바로 null을 반환하여 렌더링을 건너뛴다.
return null;
}
}
// 자식 컴포넌트에 작업이 있는 경우 현재 Fiber의 자식들을 복제하고 첫 번째 자식 Fiber를 반환한다.
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
React는 기본적으로 부모 컴포넌트가 리렌더링될 때 모든 자식 컴포넌트를 리렌더링한다.
하지만 props가 변경되지 않은 경우, React는 자식 컴포넌트의 리렌더링을 최적화한다. 이는 React.memo나 PureComponent와 같은 최적화 기법을 사용하지 않아도 기본적으로 적용되는 동작이다.
이러한 최적화는 React의 재조정(reconciliation) 과정에서 이루어지며, 불필요한 렌더링을 방지하여 성능을 향상시킨다.
🤷♂️ React의 기본 최적화가 있는데 React.memo와 같은 최적화 기법을 왜 사용하는걸까?
➔ React.memo는 React의 기본 최적화를 보완하고 더 세밀하게 명시적인 성능 최적화를 가능하게 한다.
- React의 기본 최적화는 얕은 비교만 수행하는데 React.memo를 사용하면 더 세밀한 비교 로직을 구현할 수 있어, 복잡한 props 구조에서도 불필요한 리렌더링을 방지할 수 있다.
- 규모가 큰 애플리케이션에서 React.memo를 전략적으로 사용하면, React의 기본 최적화보다 더 효과적으로 성능을 향상시킬 수 있다.
useState - React 공식문서(v18.3.1)
How does useState() work internally in React?
Exploring React’s Fiber Architecture: A Comprehensive Guide
Why React Re-Renders
(번역) React는 내부적으로 re-render를 어떻게 처리할까?