리액트 톺아보기 ③ - Hooks 동작 원리

나주엽·어제
0

리액트 톺아보기

목록 보기
3/3
post-thumbnail

React Hooks는 함수형 컴포넌트에서도
상태, 컴포넌트의 생명주기 등 React의 기능들을 사용할 수 있게 만들어준다.

Rules of Hooks

React의 공식 문서에 보면, Hooks의 규칙이 소개되어있다.

Only call Hooks at the top level

use로 시작하는 함수인 Hook을 최상위 레벨에서만 호출하도록 되어있다.

  • 함수형 컴포넌트의 본문 최상위 레벨에서 호출할 것
  • 커스텀 Hook의 본문 최상위 레벨에서 호출할 것

특히, 조건문이나 반복문 내부, 그리고 조건부 return 문 이후에 Hook을 호출하는 것은 막혀있다.

이는, React가 렌더링 시에 Hook의 호출 순서를 기반으로 내부 상태를 관리하기 때문이라고 한다.

즉, 조건문이나 반복문 등에서 훅의 호출 순서가 달라지면, 각 렌더링에서 어떤 훅이 어떤 상태와 연결되는지를 예측할 수 없게 되어 문제가 발생할 수 있다는 것이다.

느낌으로는 알겠지만, 아직 어렵다. 직접 구현해보면서 따라해본다.

useState

일단 useState를 간단하게 만들어보자. 실제와 다르다.

const ReactX = (() => {
  const useState = (initialValue) => {
    let state = initialValue

    const setState = (newValue) => {
      state = newValue
    }

    return [state, setState] 
  }

  return { useState }
})()

이 useState는 문제가 있다. 이 방식은 상태가 컴포넌트 호출 사이에 유지되지 않는다.
위 ReactX 아래에 다음과 같이 작성했다.

const { useState } = ReactX

const Component = () => {
  const [counter, setCounter] = useState(1)

  console.log(counter)

  if (counter !== 2) {
    setCounter(2)
  }
}

Component() // 1차 호출
Component() // 2차 호출

1이 출력된 이후에 2가 출력될 것 같지만, 모두 1이 출력된다.

이는, useState 내부의 state가 함수가 호출될 때마다 새로 생성되는 지역 변수이기 때문이다.
따라서, 카운터를 2로 변경해도, 함수가 종료되면서 사라지기 때문에, 다시 1로 초기화된다.

state 변수를 외부로 이동시키면 이 문제를 해결할 수 있다.

const ReactX = (() => {
  let state

  const useState = (initialValue) => {
    if (state === undefined) {
      state = initialValue
    }

    const setState = (newValue) => {
      state = newValue
    }

    return [state, setState] 
  }

  return { useState }
})()

const { useState } = ReactX

const Component = () => {
  const [counter, setCounter] = useState(1)

  console.log(counter)

  if (counter !== 2) {
    setCounter(2)
  }
}

Component() // 1 출력
Component() // 2 출력

하지만, 단 하나의 상태만 존재할 수 있게 되기 때문에 여전히 문제가 존재한다.

실제 React는 상태가 무한히 가능하기 때문에 이를 배열로 관리하고,
index 를 사용해 접근할 수 있도록 변경한다.

const ReactX = (() => {
  let state = []
  let index = 0

  const useState = (initialValue) => {
    const localIndex = index
    index++

    if (state[localIndex] === undefined) {
      state[localIndex] = initialValue
    }

    const setState = (newValue) => {
      state[localIndex] = newValue
    }

    return [state[localIndex], setState]
  }

  const resetIndex = () => {
    index = 0
  }

  return { useState, resetIndex }
})()

const { useState, resetIndex } = ReactX

const Component = () => {
  const [counter, setCounter] = useState(1)

  console.log(counter)

  if (counter !== 2) {
    setCounter(2)
  }
}

Component() // 1차 호출
resetIndex()
Component() // 2차 호출

이제 React 의 useState와 비슷하게 동작하도록 만들 수 있다.

React Hook의 상태 관리는 사실 연결 리스트를 사용하고 있다.
이 배열로 만든 Hook과 마찬가지로 항상 동일한 순서로 Hook이 호출되어야 하는 것은 당연하다.

실제로 1, 2 가 차례로 출력된다.

이때, 알맞는 상태에 접근하기 위해 localIndex 를 사용하도록 했다.

만약, 조건문이나 반복문에 Hook의 호출이 이루어진다면, 매 렌더링에 이 localIndex 의 값이 달라질 것이고, 그렇다면 의도대로 상태에 접근할 수 없을 것이다.

useEffect

이번엔 useEffect다.

statehooks 로 이름만 변경한 후, useEffect를 만들었다.

const ReactX = (() => {
  let hooks = []
  let index = 0

  const useState = (initialValue) => {
	  // ...
  }

  const resetIndex = () => {
    index = 0
  }

  const useEffect = (callbackFn, dependencies) => {
    let hasChanged = true

    const oldDependencies = hooks[index]

    if (oldDependencies) {
      hasChanged = false

      dependencies.forEach((dependency, index) => {
        const oldDependency = oldDependencies[index]
        const areSame = Object.is(dependency, oldDependency)
        if (!areSame) {
          hasChanged = true
        }
      });
    }

    if (hasChanged) {
      callbackFn()
    }

    hooks[index] = dependencies
    index++
  }

  return { useState, useEffect, resetIndex }
})()

const { useState, useEffect, resetIndex } = ReactX

const Component = () => {
  useEffect(() => {
    console.log('Effect')
  }, [])
}

Component() // 1차 호출
resetIndex()
Component() // 2차 호출
resetIndex()
Component() // 3차 호출

그리고, 결과를 보면 최초에만 실행되는 것을 볼 수 있다.

마찬가지로, 컴포넌트를 아래와 같이 바꾸면, 의존성 배열 내의 값이 변경될 때만 동작함을 알 수 있다.

const Component = () => {
  const [counter, setCounter] = useState(1)
  const [changed, setChanged] = useState(false)

  console.log(counter)
  
  useEffect(() => {
    console.log('Effect')
  }, [changed])

  if (counter !== 2) {
    setCounter(2)
  }

  if (!changed && counter === 2) {
    setChanged(true)
  }
}

Component() // 1차 호출
resetIndex()
Component() // 2차 호출
resetIndex()
Component() // 3차 호출

카운터는 세 번 출력되지만, useEffect는 최초와 changed가 변경될 때만 동작함을 알 수 있다.

이렇게 useState와 useEffect의 구조를 보면 Hook의 규칙을 이해하는 것이 쉽다.

이는, React가 상태와 의존성 배열을 순서로 관리하기 때문이다.

즉, React는 내부적으로 Hook들을 배열(사실은 연결 리스트인 것 같다)로 관리하는데,
렌더링 시 Hook의 호출 순서가 일정해야 각 Hook과 올바른 상태, 의존성 배열을 연결할 수 있기 때문이다.

다음으로는 Concurrent Mode 에 대해 더 알아본다.

References

모던 리액트 Deep Dive
How Do React Hooks Actually Work? React.js Deep Dive #3

0개의 댓글

관련 채용 정보