공부한 내용을 정리한 글입니다
내용에 오류가 있거나 더 좋은 의견이 있다면 댓글로 남겨주세요.
배움에 큰 도움이 됩니다. 🖋
Hook 객체의 탄생
지난 시간에는 개발자가 hook 함수를 호출했을 때 react가 그 hook의 구현체를 찾는 여정을 따라가보았다.
오늘은 대표적인 hook useState()를 통해 hook 객체의 탄생 과정을 살펴보자.
// reconciler > ReactFiberHooks.js
// 인자로 받은 initialState는 함수형 컴포넌트에서 해당 상태의 최초값이다.
function mountState(initialState) {
// 새로운 hook 객체를 생성하고 현재 처리 중인 hook 리스트에 추가한다.
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
// initialState가함수일 경우, 실행한 결과를 새로운 초기 state로 사용한다.
initialState = initialState()
}
hook.memoizedState = hook.baseState = initialState
...
// 생성한 hook 반환
return hook;
}
mountState()는 컴포넌트가 처음 마운트 될 때 호출 되는 함수다.
이 함수는 mountWorkInProgressHook() 함수를 통해 hook 객체를 생성하고 초기값을 설정한다.
// reconciler > ReactFiberHooks.js
function mountWorkInProgressHook(): Hook {
// 1. 새로운 hook 객체를 생성해 초기값을 설정하고
const hook: Hook = {
memoizedState: null, // 컴포넌트에 적용된 마지막 상태 값
queue: null, // hook이 호출될 때마다 update를 연결 리스트로 queue에 집어넣는다.
next: null, // 다음 hook을 가리키는 포인터
baseState: null,
baseUpdate: null,
}
// 2. 해당 hook을 현재 처리 중인 hook 리스트에 추가한다.
if (workInProgressHook === null) {
// 맨 처음 실행되는 hook인 경우 연결 리스트의 head로 잡아두고
firstWorkInProgressHook = workInProgressHook = hook
} else {
// 두번 째부터는 연결 리스트에 추가한다
workInProgressHook = workInProgressHook.next = hook
}
// 생성한 hook 반환
return workInProgressHook
}
mountWorkInProgressHook() 함수는 새로운 hook 객체를 생성해 초기값을 설정하고, 해당 hook을 현재 처리 중인 hook 리스트에 추가하는 역할을 한다.
React에서는 Hook을 관리하기 위해 연결 리스트라는 자료 구조를 사용한다. 연결 리스트는 각 노드가 데이터, 그리고 다음 노드를 가리키는 포인터를 가진 구조인데, 배열에 비해 리스트의 탐색 흐름을 쉽게 제어할 수 있고, 노드의 추가/삭제나 리스트 병합이 효율적인 것이 장점이다.
[ hook에서 head와 tail 역할을 하는 요소들 ]
firstWorkInProgressHook
- Hook 연결 리스트의 첫 번째 요소(head)다
- 컴포넌트 실행이 끝나면 이 요소는 fiber에 저장되어 컴포넌트와 Hook 리스트를 연결해준다
workInProgressHook
- 현재 처리되고 있는 hook을 나타낸다.
- 동시에 리스트의 마지막 요소(tail) 포인터 역할을 한다
* Fiber는 react의 핵심 알고리즘 "Fiber Reconciliation"에서 사용되는 개념으로, 작업 단위를 나타낸다.
Fiber는 JS 객체로, 컴포넌트와 그 상태, 그에 대한 다양한 정보를 포함하며 이를 통해 react는 컴포넌트 트리의 업데이트를 효과적으로 관리할 수 있다.
각 hook은 호출한 순서에 따라 연결 리스트에 추가되기에 컴포넌트 내에서 여러 hook의 상태가 독립적으로 관리될 수 있다. 이것은 함수형 컴포넌트가 상태를 유연하게 관리하고, 렌더링 동안 일관된 동작을 하는 데 중요한 한다.
import React, { useState, useEffect } from "react";
const Profile = () => {
const [age, setAge] = useState(0); // 연결리스트의 첫 번째 노드
const [isVisible, setIsVisible] = useState(false);// 두 번째 노드
useEffect(() => { // 세 번째 노드
...
});
return (
<div>
<p>나이 : {age}세</p>
{isVisible && (
<div>...</div>
)}
...
</div>
);
}
export default Profile;
예를 들어, 위의 코드에서 연결리스트는 호출한 순서에 따라 useState(0) -> useState(false) -> useEffect(...) 순으로 생성된다.
만약 두번째 hook인 'useState(false)'가 특정 조건일 때는 호출되지 않는다면,
첫 번째 렌더링에서는 useState(0) -> useState(false) -> useEffect(...) 순서로 연결리스트가 생성되지만 두 번째 렌더링에서는 조건에 따라 useState(0) -> useEffect(...)로 변경될 수 있다. 그렇게 되면 react는 'useEffect(...)'를 두 번째 hook으로 인식하게 되며 이전 렌더링에서 'useState(false)'에 연결된 상태 정보를 잘못 사용하게 된다.
이런 이유로 hook은 항상 컴포넌트의 최상위 레벨에서 호출되어야 하며, 반복문, 조건문, 중첩된 함수 내에서 호출하면 안 되는 것이다. 그렇게 되면 react에서 컴포넌트 상태를 제대로 관리하기 어렵기 때문이다.
이제야 내가 그 동안 받은 'useState' is called conditionally 경고장이 이해된다.
업데이트를 담는 queue
// reconciler > ReactFiberHooks.js
// mountState 함수: 초기 상태와 함께 호출되는 Hook 함수
function mountState(initialState) {
// queue 객체 생성 및 초기화
const queue = (hook.queue = {
last: null, // 마지막 update
dispatch: null, // push 함수
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
});
// dispatch 함수 생성 및 할당
const dispatch = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber,
queue
));
// 초기 state, dispatch 함수 배열로 반환
return [hook.memoizedState, dispatch];
}
// basicStateReducer 함수: 기본적인 상태 갱신 로직을 수행하는 리듀서
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
다시 mountState() 함수로 돌아와 이번에는 hook의 업데이트를 살펴볼 것이다.
Hook에는 queue라는 속성이 있는데, queue는 컴포넌트의 상태 업데이트 정보를 저장하는 곳이다.
const Profile = () => {
const [name, setName] = React.useState("");
const [age, setAge] = React.useState(0);
React.useEffect(() => {
fetch("/api/user")
.then((response) => response.json())
.then((data) => {
setName(data.name);
setAge(data.age);
});
}, []);
return (
<div>
<h1>User Profile</h1>
<p>Name: {name}</p>
<p>Age: {age}</p>
</div>
);
}
export default Profile
이 컴포넌트는 name과 age라는 두 개의 상태를 관리하고 있다. 각 상태는 각각의 useState hook에 의해 관리되며, 호출 순서에 따라 연결 리스트에 노드로 추가된다. 각 hook은 상태 업데이트 정보를 자신의 queue에 저장하고, 컴포넌트가 리렌더링될 때 queue에 저장된 업데이트들이 순서대로 실행되어 최종 상태를 결정한다.
반면에, 하나의 setState 함수가 여러 번 호출되면 어떻게 될까?
아래의 또다른 Profile 컴포넌트에서는 setProfile 함수가 두 번 호출된다.
const Profile = () => {
const [profile, setProfile] = React.useState({
name: '',
age: 0,
});
const handleUpdateProfile = () => {
// setState()가 여러 번 호출되고 있다.
setProfile((prevProfile) => ({ ...prevProfile, name: '토니' }));
setProfile((prevProfile) => ({ ...prevProfile, age: '20' }));
};
return (
<div>
<h1>User Profile</h1>
<p>Name: {profile.name}</p>
<button onClick={handleUpdateProfile}>Update Profile</button>
</div>
);
}
export default Profile
이 경우 setProfile() 함수가 호출될 때마다 상태 업데이트 정보가 해당 hook 객체의 queue에 연결 리스트 형태로 저장된다. 저장된 업데이트들은 컴포넌트가 리렌더링될 때 순서대로 실행되며 profile 상태를 최종적으로 결정한다. 이 방식으로, react는 여러 번의 상태 업데이트를 효율적으로 관리한다.
정리하기
React에서 hook 객체는 컴포넌트가 처음 마운트될 때 mountState() 함수를 통해 생성된다.
각 hook은 호출된 순서대로 연결리스트에 추가돼 순서가 보장되고, 각 hook은 자신의 업데이트된 상태를 queue에 저장한다.
hook의 호출 순서가 항상 일정하지 않으면 상태 관리에 문제가 발생할 수 있기 때문에 hook의 호출 순서는 언제나 동일하게 보장되어야 한다.
setState() 함수가 여러 번 호출되면 각 호출은 해당 hook의 queue에 저장되고 이 정보들은 리렌더링 시 순차적으로 실행되며 상태를 최종 결정한다.
참고
https://goidle.github.io/react/in-depth-react-hooks_1/
댓글