React Hook의 동작

React Hook의 동작에 대해

2021-11-07

React Hook

React로 개발할 땐 클래스형 컴포넌트 보다 React 16.8 부터 도입된 Hook 기반 함수형 컴포넌트를 더 선호하여 개발하는 것 같다.

기존 클래스형 컴포넌트는 여러 단계의 상속과 복잡성, 오류 등이 많았지만 Hook이 도입되면서 클래스형 컴포넌트가 가지고 있는 기능을 모두 사용할 수 있음은 물론이고 복잡성과 재사용성의 단점들까지도 해결됐다.


클로저(Closure)

Hook의 핵심은 JS의 클로저이다. 클로저는..봐도봐도 잘 이해가 가지 않는다ㅠㅠ

클로저 = 함수 + 함수를 둘러싼 환경 이라고 말할 수 있는데, 자바스크립트는 함수 안에서도 함수를 선언할 수 있다. 먼저 선언된 함수를 외부함수, 이후에 선언된 함수를 내부함수라 한다면 기본적으로 내부함수는 외부함수의 요소에 접근이 가능하다. 즉 외부함수의 변수에 내부함수의 변수가 접근 할 수 있는 자바스크립트의 메커니즘이다.

function outerFn() {
  let outerVar = 'outer'
  console.log(outerVar)

  //클로저 함수
  function innerFn() {
    let innerVar = 'inner'
    console.log(innerVar)
  }
  //클로저 함수 안에서는
  //지역변수(innerVar)
  //외부함수의 변수(outerVar)
  //전역변수(globalVar)
  //접근이 모두 가능하다.
  return innerFn
}
let globalVar = 'global'
let innerFn = outerFn()
innerFn()

useState

function useState(initVal) {
  let _val = initVal
  const state = _val
  const setState = newVal => {
    _val = newVal
  }
  return [state, setState]
}
const [count, setCount] = useState(1)
console.log(count) // 1
setCount(2)
console.log(count) // 1 (?)

위 함수에서 count는 한번 가져오고 끝난 값이기 때문에 즉각적으로 바뀌지 않는다. 만약 const state = _val 부분을 함수로 바꾸고, 값을 쓰는게 아닌 호출해주는 식으로 바꾼다면 호출할 때마다 값을 가져오기 때문에 setCount가 반영된 값을 가져올 수 있다.

// useState 안에서
// ...
const state = () => _val
// ...
const [count, setCount] = useState(1)
console.log(count()) // 1
setCount(2)
console.log(count()) // 2

state는 상단에 정의된 _val를 반환하고, setState는 전달 된 매개변수 newVal를 지역 변수로 설정한다.


함수형 컴포넌트에서 사용하기

const React = (function() {
  function useState(initVal) {
    let _val = initVal
    const state = _val
    const setState = newVal => {
      _val = newVal
    }
    return [state, setState]
  }
  function render(Component) {
    const C = Component()
    C.render()
    return C
  }
  return { useState, render }
})()
function Component() {
  const [count, setCount] = React.useState(1)
  return {
    render: () => console.log(count),
    click: () => setCount(count + 1),
  }
}
// 아직까진 중간 과정이므로 제대로 동작하지 않는다.
var App = React.render(Component)
App.click()
var App = React.render(Component)

모듈패턴을 이용해 React라는 네임스페이스에 useState를 집어넣는다. 그리고 DOM을 사용하진 않지만 가상의 컴포넌트를 만들어 useState 훅을 가져다 쓰는 방식이다.

여기서 count가 제대로 동작하게 만들기 위해 _val로 쓰고 있던 변수를 React 내부로 끌어올리면 랜더링 이후 클릭해도 작동한다.

const React = (function() {
  let _val
  function useState(initVal) {
    const state = _val || initVal
    // ...
  }
  // ...
})()
var App = React.render(Component) // 1
App.click()
var App = React.render(Component) // 2
App.click()
var App = React.render(Component) // 3
App.click()
var App = React.render(Component) // 4

여러 개의 훅

하지만 실제로 하나의 컴포넌트에서 여러 상태를 관리하기 위해 여러 훅을 사용하는데. _val 하나에 의존한 지금 상태로 useState를 두번 호출하게되면

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: word => setText(word),
  }
}
var App = React.render(Component) // {count: 1, text: 'apple'}
App.click()
var App = React.render(Component) // {count: 2, text: 2}
App.type('banana')
var App = React.render(Component) // {count: 'banana', text: 'banana'}

중간에 값이 덮어씌워 진다. 이를 관리 하려면 각 값별로 배열에 담아 다루면 된다.

const React = (function() {
  let hooks = []
  let idx = 0
  function useState(initVal) {
    const state = hooks[idx] || initVal
    const _idx = idx // 이 훅이 사용해야 하는 인덱스를 가둬둔다.
    const setState = newVal => {
      hooks[_idx] = newVal
    }
    idx++ // 다음 훅은 다른 인덱스를 사용하도록 한다.
    return [state, setState]
  }
  function render(Component) {
    idx = 0 // 랜더링 시 훅의 인덱스를 초기화한다.
    const C = Component()
    C.render()
    return C
  }
  return { useState, render }
})()

여기까지보면 왜 Hook에 기본 규칙이 있는지 알 수 있다.

  1. 최상위(Top Level)에서만 Hook을 호출
  2. 오직 React 함수 내에서 Hook을 호출

useEffect

useEffect를 사용하면 컴포넌트를 화면에 그린 후 실행될 함수를 정의할 수 있다. 또 매번 render했을 때 최초 한 번만 실행되며 매 업데이트마다 실행된다.

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')
  // 랜더링 시 최초에 한 번만 실행된다.
  // 배열 안에 관찰하고자 하는 상태를 전달하면 그 상태에 반응하여 콜백이 실행된다.
  React.useEffect(() => {
    console.log('side effect')
  }, [])
  // ...
}
function useEffect(cb, depArray) {
  const oldDeps = hooks[idx] // 이미 저장되어있던 의존 값 배열이 있는지 본다.
  let hasChanged = true
  if (oldDeps) {
    // 의존 값 배열의 값 중에서 차이가 발생했는지 확인한다.
    // 실제로 리액트 구현체도 `Object.is` 로 값을 비교한다. 정확한 동작은 MDN 참고.
    hasChanged = depArray.some((dep, i) => !Object.is(dep, oldDeps[i]))
  }
  // 값이 바뀌었으니 콜백을 실행한다.
  if (hasChanged) {
    cb()
  }
  // useEffect도 훅의 일부분이다. hooks 배열에 넣어서 관리해준다.
  hooks[idx] = depArray
  idx++
}

위에 선언된 React 모듈 안에 useEffect함수를 정의한다. 두번째 인자로 넣어둔 의존배열(dependency array)을 관찰하며 값이 변하면 콜백을 실행하고, 그렇지 않으면 실행하지 않는다.


참조

위 글은 아래 두 블로그를 정독하며 정리하였습니다.

Deep dive: How do React hooks really work?

Getting Closure on React Hooks