react-hooks/rules-of-hooks error를 마주해 보신 적 있으신가요?
const [a, setA] = useState('a');
if (a === 'a') {
const [b, setB] = useState('b'); // eslint error
}
위와 같이 코드를 선언하면 아래와 같은 error를 마주하게 됩니다.
React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. eslint react-hooks/rules-of-hooks
React Hooks는 모든 컴포넌트 렌더링마다 정확하게 같은 순서로 호출되어야 한다고 합니다.
React Hook Rule 공식문서를 보면 지켜야 할 2가지 조건이 있습니다.
1. 최상위(at the Top Level)에서만 Hook을 호출해야 한다.
2. 오직 React 함수 내에서 Hook을 호출해야 한다.
그중 첫 번째 조건에 대한 설명을 더 살펴보겠습니다.
반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출하면 안 된다.
early return이 실행되기 전에 항상 React 함수의 최상위에서 Hook을 호출해야 한다.
이 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장된다.
이러한 점은 React가 useState와 useEffect가 여러 번 호출되는 중에도 Hook의 상태를 올바르게 유지할 수 있다.
위 글만 읽고도 바로 이해가 되셨을까요?
왜 React Hook은 최상위에서 호출되어야 하고, 컴포넌트가 렌더링 될 때마다 동일한 순서로 호출되는 것이 보장되어야 할까요?
React가 특정 state가 어떤 useState 호출에 해당하는지 알 수 있는 이유는 React가 Hook이 호출되는 순서에 의존하기 때문입니다.
React는 첫 번째 실행한 순서대로 Hook을 기억하고 있기 때문에, 만약 조건문 등으로 특정 Hook이 실행되지 않는다면 Hook의 순서가 틀어져 React가 이전 상태값을 제대로 매칭하지 못해 문제가 발생하게 되는 것입니다.
조금 더 자세히 이해하기 위해 리액트의 내부 구현 방식을 통해 알아보겠습니다.
자주 등장하죠? 나만의 React 만들기를 진행했던 코드 기반으로 다시 이해해 보겠습니다.
Hooks를 구현하는 부분은 위 글에 정리해 놨습니다.
useState(hook)를 구현하기 위해 작성했던 코드 중 hook을 선언하는 부분의 코드를 다시 살펴보겠습니다.
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
순서를 어떻게 관리하는가에 대한 의문의 해결점이 바로 등장했습니다. hook Index입니다.
hookIndex는 fiber(컴포넌트)에서 훅이 사용된 순서를 추적하는 인덱스입니다.
fiber(컴포넌트)가 사용할 훅을 저장할 hooks 배열도 초기화하는 코드를 확인할 수 있죠.
사실 벌써 정답이 유추가 됩니다. hooks에 순차적으로 저장하고 hookIndex로 접근하겠구나!
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
setState를 반환하기 전 코드까지만 봐도 충분합니다.
useState가 호출되면 hookIndex를 통해 이전에 초기화되었다면 이전 값을, 아니라면 initial을 새 hook의 state로 설정합니다.
실제 코드를 보면서 다시 정리를 해보겠습니다.
const Main = () => {
const [a, setA] = useState('a') // 1
const [b, setB] = useState('b') // 2
return (
<div>{a}, {b}</div>
)
}
간단하고 설명을 위한 코드스럽게 컴포넌트 코드를 작성해 봤습니다.
Main이라는 컴포넌트가 있고, useState를 2개 선언해 단순히 보여주는 컴포넌트입니다.
이제 내부적으로 동작하는 것을 순서대로 적어보겠습니다.
[컴포넌트가 처음 렌더링 된 경우]
- wipFiber.alternate.hooks 배열은 비어 있으므로 oldHook은 존재하지 않습니다.
- 때문에 1번 코드가 실행되면 useState에서 initial 값으로 전달한 'a'로 초기화됩니다. 그리고 hooks 배열에 추가합니다.
-> 여기까지 Main 컴포넌트의 hooks는 [{state: 'a'}]가 됩니다. hookIndex는 1 증가해 1이 되었습니다.
- 2번 코드가 실행되면 useState에서 initial 값으로 전달한 'b'로 초기화됩니다. 그리고 hooks 배열에 추가합니다.
-> 여기까지 하면 Main 컴포넌트의 hooks는 [{state: 'a'}, {state: 'b'}]가 됩니다. hookIndex는 1 증가해 2가 되었습니다.
[컴포넌트가 두 번째로 렌더링 된 경우]
- wipFiber.alternate.hooks 배열은 [{state: 'a'}, {state: 'b'}] 값을 가지고 있습니다.
- 1번 코드가 실행되면 wipFiber.alternate.hooks[hookIndex]로 접근해 oldHook을 통해 이전 렌더링에서 사용된 상태 값을 가져오게 됩니다. 2번 코드도 마찬가지겠죠?
이 방식으로 리액트의 Hook 시스템은 컴포넌트가 매번 새로 호출되더라도 상태가 연속적으로 유지되도록 해주며, 컴포넌트가 변경된 부분만 효율적으로 다시 렌더링 할 수 있게 합니다.
React Hook 룰에서 최상단에 위치해야 하고, 조건문 등에 영향을 받지 않고 순서대로 실행되어야 하는 이유가 바로 index로 참조되기 때문이라는 점 아주 확실하게 이해되었습니다!
문제 될 상황까지 한번 살펴보고 마무리하겠습니다~!
const Test = () => {
const [a, setA] = useState("a");
if (a === "a") {
const [b, setB] = useState("b");
}
const [c, setC] = useState("c");
};
만약, 이 코드가 실행이 된 후 첫 번째 렌더링이 되면 hooks에 순차적으로 [{state: 'a'}, {state: 'b'}, {state: 'c'}]가 될 텐데요.
setA('a1')으로 실행되면 useState('b')는 실행되지 않기 때문에 index 1에 useState('c')가 실행되어 들어가겠죠?
그럼 내부 로직에 따라 oldHook이 있다면 oldHook에 index에 있는 state를 사용하게 되는데 {state: 'b'}가 사용되는 문제가 발생합니다.
앞으로는 react-hooks/rules-of-hooks error를 마주해도 이해하고 바로 수정할 수 있게 되었습니다 :)
'FRONT-END > React' 카테고리의 다른 글
웹 성능 최적화 - 정적 리소스 최적화 (0) | 2024.11.08 |
---|---|
웹 성능 최적화 필요성 이해하기 (1) | 2024.11.07 |
React에서 컴포넌트를 분리한다는 관점에 대해 다시 생각해보기 (2) | 2024.10.21 |
Suspense 이해하기 (0) | 2024.10.08 |
memo, useMemo 이해하기 (1) | 2024.09.11 |
댓글