Hooks 패턴

React 16.8 버전에는 Hooks라는 기능이 추가되었다. Hooks는 React의 상태와 생명 주기 함수들을 ES2015의 클래스를 사용하지 않고 쓸 수 있게 해 준다.

Hooks가 디자인 패턴이 아닐순 있지만 Hooks는 앱에서 아주 중요한 역할을 한다. 여러 전통적인 디자인 패턴들은 모두 Hooks로 변경할 수 있다.


클래스 컴포넌트

Hooks가 추가되기 전에 React에서 상태와 생명 주기 함수를 사용하려면 클래스 컴포넌트를 꼭 사용해야 했다. 일반적인 React의 클래스 컴포넌트는 아래 예제 코드와 같이 생겼다.

class MyComponent extends React.Component {
  /* Adding state and binding custom methods */
  constructor() {
    super()
    this.state = { ... }

    this.customMethodOne = this.customMethodOne.bind(this)
    this.customMethodTwo = this.customMethodTwo.bind(this)
  }

  /* Lifecycle Methods */
  componentDidMount() { ...}
  componentWillUnmount() { ... }

  /* Custom methods */
  customMethodOne() { ... }
  customMethodTwo() { ... }

  render() { return { ... }}
}

클래스 컴포넌트는 생성자에서 상태를 선언했고. componentDidMountcomponentWillUnmout같은 생명 주기 메서드들은 컴포넌트의 생명 주기를 기준으로 사이드 이펙트를 발생시켰고. 그 외에 추가 동작을 위한 메서드들을 선언할 수 있었다.

Hooks를 이용할 수 있게 된 지금에도 클래스 컴포넌트를 활용할 수 있지만 몇가지 단점이 존재한다. 클래스 컴포넌트를 활용할때의 단점을 살펴보자.

ES2015의 클래스를 알아야 한다

Hooks가 추가되기 전에는 상태와 생명 주기 메서드를 쓰려면 클래스 컴포넌트로 만들어야 했기 때문에 해당 기능을 쓰기 위해서는 종종 함수형 컴포넌트를 클래스형 컴포넌트로 리펙토링해야만 했다.

아래 예제에서는 div를 버튼으로 사용하고 있다.

function Button() {
  return <div className="btn">disabled</div>
}

컴포넌트는 항상 disabled를 보여줄 수 밖에 없지만 사용자가 버튼을 클릭했을 때에는 enabled로 보여주고 싶고. 버튼처럼 보이도록 CSS를 추가하려고 한다.

그러기 위해서는 enabled인지 disabled인지 상태를 유지해야 한다. 결국 함수형 컴포넌트를 클래스형 컴포넌트로 리펙토링하고 상태를 가지도록 리펙토링해야 했다.

export default class Button extends React.Component {
  constructor() {
    super()
    this.state = { enabled: false }
  }

  render() {
    const { enabled } = this.state
    const btnText = enabled ? 'enabled' : 'disabled'

    return (
      <div
        className={`btn enabled-${enabled}`}
        onClick={() => this.setState({ enabled: !enabled })}
      >
        {btnText}
      </div>
    )
  }
}

이렇게 하여 버튼은 원하는 동작을 할 수 있게 되었다.

import React from 'react'
import './styles.css'

export default class Button extends React.Component {
  constructor() {
    super()
    this.state = { enabled: false }
  }

  render() {
    const { enabled } = this.state
    const btnText = enabled ? 'enabled' : 'disabled'

    return (
      <div
        className={`btn enabled-${enabled}`}
        onClick={() => this.setState({ enabled: !enabled })}
      >
        {btnText}
      </div>
    )
  }
}

예제에서 쓰인 컴포넌트는 작아서 리펙토링 하는 데 별로 어렵지 않았지만 실무에서는 사용되는 컴포넌트를 이렇게 리펙토링 하기는 매우 까다롭다.

또 리펙토링 과정에서 동작을 의도하지 않게 변경하지 않으려면 ES2015클래스에 대해서 알고 있어야 한다. 왜 메서드들에 bind를 사용했지? 생성자는 무엇을 하지? this키워드는 어디서 온 것이지? 이런것들을 고려하는 것은 어렵기 때문에 동작을 유지한 채로 리펙토링 하는것은 까다롭다.

재설계

여러 컴포넌트에서 코드를 공유하기 위해 HOC패턴이나 Render Prop 패턴을 사용한다. 이 패턴들도 유효하며 좋은 방법이지만 나중에 이런 패턴들을 도입하려 할 땐 구조를 재설계해야 할 수 있다.

컴포넌트의 크기가 클수록 앱을 재구성하기 까다롭다는 것 외에도. 중첩된 컴포넌트간에 코드를 공유하기 위해 컴포넌트 래핑을 많이 하면 Wrapper Hell이라는 안티 패턴이 나타날 수 있다. 개발 도구를 열었을 떄 아래 구조를 확인하기 어렵지 않을 것이다.

<WrapperOne>
  <WrapperTwo>
    <WrapperThree>
      <WrapperFour>
        <WrapperFive>
          <Component>
            <h1>Finally in the component!</h1>
          </Component>
        </WrapperFive>
      </WrapperFour>
    </WrapperThree>
  </WrapperTwo>
</WrapperOne>

Wrapper Hell은 앱 내에서 데이터가 어떻게 흘러가는지 파악하기 어렵게 만들 뿐더러, 어떤 동작이 이뤄지고 있는지도 알기 어렵게 만든다.

복잡도

클래스 컴포넌트에 로직을 추가해 갈 수록 컴포넌트의 크기는 빠르게 증가한다. 컴포넌트 내의 로직들은 서로 얽히고 분리하기 점점 더 어려워져 개발자가 클래스 컴포넌트 안에서 무슨 로직이 동작하는지 알기 어렵게 만든다. 결국 디버깅과 성능 최적화를 어렵게 만든다.

생명주기 메서드들은 꽤 많은 코드의 중복을 만들어낸다. 아래 예시는 Counter컴포넌트와 Width컴포넌트를 활용하고 있다.

import React from 'react'
import './styles.css'

import { Count } from './Count'
import { Width } from './Width'

export default class Counter extends React.Component {
  constructor() {
    super()
    this.state = {
      count: 0,
      width: 0,
    }
  }

  componentDidMount() {
    this.handleResize()
    window.addEventListener('resize', this.handleResize)
  }

  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize)
  }

  increment = () => {
    this.setState(({ count }) => ({ count: count + 1 }))
  }

  decrement = () => {
    this.setState(({ count }) => ({ count: count - 1 }))
  }

  handleResize = () => {
    this.setState({ width: window.innerWidth })
  }

  render() {
    return (
      <div className="App">
        <Count
          count={this.state.count}
          increment={this.increment}
          decrement={this.decrement}
        />
        <div id="divider" />
        <Width width={this.state.width} />
      </div>
    )
  }
}

App컴포넌트의 구조를 시각화 하면 아래와 같다.

예시의 컴포넌트는 작은 컴포넌트이지만 컴포넌트 내 로직들은 이미 서로 얽혀있다. 코드의 일부는 counter를 위한 로직이고 다른 일부는 width를 위한 로직이다. 컴포넌트 크기가 커 지면 구조를 파악하기 어렵게 만들고 각 컴포넌트와 관련된 로직을 추려내기도 어려워 질 것이다.

복잡하게 얽힌 로직 뿐만 아니라 일부 로직은 생명주기 함수 내에서 중복되어 있다. componentDidMountcomponentWillUnmount 양쪽에서 윈도우의 resize이벤트를 기준으로 동작을 커스터마이징 하고 있다.


Hooks

React의 클래스 컴포넌트가 항상 좋은 방법은 아니다. React 개발자가 클래스 컴포넌트를 개발할 때 겪는 문제들을 해결하기 위해 React는 Hooks를 추가했다. React Hooks는 컴포넌트의 상태와 라이프사이클 메서드를 관리할 수 있는 함수이다. React Hooks은 다음의 항목들을 가능하게 한다.

  • 함수형 컴포넌트에 상태를 추가한다
  • componentDidMount 혹은 componentWillUnmount와 같은 생명주기 메서드 없이도 컴포넌트의 생명 주기를 관리할 수 있다.
  • 앱 내에 상태를 가진 로직을 여러 컴포넌트에서 재사용할 수 있게 한다.

먼저 Hooks을 사용하여 함수형 컴포넌트에 어떻게 상태를 추가할 수 있는지 확인해 보자.

State Hook

React는 함수형 컴포넌트에서 상태 관리를 할 수 있도록 useState훅을 제공한다.

아래 예제를 통해 useState훅을 사용하여 클래스 컴포넌트를 어떻게 함수형 컴포넌트로 재구성 할 수 있는지 확인해 보자. Input컴포넌트는 단순히 input요소 하나를 렌더링 하고. 사용자의 타이핑을 상태에 업데이트 하고 있다.

class Input extends React.Component {
  constructor() {
    super()
    this.state = { input: '' }

    this.handleInput = this.handleInput.bind(this)
  }

  handleInput(e) {
    this.setState({ input: e.target.value })
  }

  render() {
    ;<input onChange={handleInput} value={this.state.input} />
  }
}

useState 훅을 사용하기 위해 먼저 React가 제공하는 useState훅을 알아보자. useState 메서드는 상태의 초기값이 될 인자를 하나 받는다. 예제에서는 빈 문자열이 될 것이다.

useState의 반환값으로 부터 두 개의 값을 구조 분해를 통해 받을 수 있다.

  1. 상태의 현재 값
  2. 상태를 업데이트 할 수 있는 함수
const [value, setValue] = React.useState(initialValue)

첫 번째 값은 클래스 컴포넌트의 this.sate.[value]에 대응하고. 두 번째 값은 클래스 컴포넌트의 this.setState 메서드에 대응된다.

우린 input요소의 값을 상태에 담아야 하므로. 초기값은 빈 문자열이 되어야 한다.

const [input, setInput] = React.useState('')

이제 Input클래스 컴포넌트를 상태를 가진 함수형 컴포넌트로 리펙토링 하였다.

function Input() {
  const [input, setInput] = React.useState('')

  return <input onChange={e => setInput(e.target.value)} value={input} />
}

클래스 컴포넌트에서 구현했던 것 처럼 input요소의 값은 input 상태와 같다. 사용자가 타이핑을 시작하면 setInput함수를 통해 input상태 역시 업데이트된다.


Effect Hook

앞서 useState를 사용하여 함수형 컴포넌트 내에서 상태를 다룰 수 있었다. 하지만 클래스형 컴포넌트의 또 다른 장점은 생명 주기 메서드를 사용할 수 있다는 것이다.

useEffect훅을 사용하면 컴포넌트의 생명주기를 가로챌 수 있다. useEffect훅은 componentDidMount, componentDidUpdate, componentWillUnmount를 조합한 듯한 효과를 낸다.

componentDidMount() { ... }
useEffect(() => { ... }, [])

componentWillUnmount() { ... }
useEffect(() => { return () => { ... } }, [])

componentDidUpdate() { ... }
useEffect(() => { ... })

위 State Hook 섹션에서 다뤘던 input의 예제를 보자. 사용자가 인풋 요소에 포커스를 둔 채로 타이핑을 시작하면 이 값을 콘솔에 출력하려 한다.

useEffect가 input값을 지켜보도록 해야 한다. inputuseEffect 훅의 의존 배열에 추가한다. 의존 배열은 useEffect의 두 번째 인자로 전달하면 된다.

useEffect(() => {
  console.log(`The user typed ${input}`)
}, [input])

아래 예제를 확인해 보자.

사용자가 인풋 필드에 값을 타이핑할때 마다 콘솔에 값이 출력되고 있다.


Custom Hooks

React가 제공하는 빌트인 훅들 (useStateuseEffectuseReduceruseRefuseContextuseMemouseImperativeHandleuseLayoutEffectuseDebugValueuseCallback) 을 이용하여 커스텀 훅을 직접 만들수도 있다.

모든 훅이 use로 시작하는것을 볼 수 있다. Rules of Hooks에 따라 모든 Hook들은 use로 시작해야 한다.

사용자가 input에 타이핑할 때 hook의 인자로 넘어온 키를 받을 수 있도록 구현해 보자.

function useKeyPress(targetKey) {}

keydown, keyup이벤트 리스너를 에서 인자로 넘어온 키 값을 감지하고. 사용자가 해당하는 키를 입력하면 keydown 이벤트가 발생한다. hook 내의 상태는 true가 될 것이다. 반대로 키에서 손을 떼면 keyup이벤트가 발생하고 상태는 false가 된다.

function useKeyPress(targetKey) {
  const [keyPressed, setKeyPressed] = React.useState(false)

  function handleDown({ key }) {
    if (key === targetKey) {
      setKeyPressed(true)
    }
  }

  function handleUp({ key }) {
    if (key === targetKey) {
      setKeyPressed(false)
    }
  }

  React.useEffect(() => {
    window.addEventListener('keydown', handleDown)
    window.addEventListener('keyup', handleUp)

    return () => {
      window.removeEventListener('keydown', handleDown)
      window.removeEventListener('keyup', handleUp)
    }
  }, [])

  return keyPressed
}

아래 예제에서는 위의 hook을 이용하여 q, l, w의 입력을 콘솔에 출력한다.

키 입력 감지 로직을 Input컴포넌트 안에 넣는 대신 useKeyPress Hook을 활용해 여러 컴포넌트에 기능을 제공할 수 있다.

Hook의 최대 장점은 개발자들이 만든 Hook들을 서로 공유할 수 있다는 점이다. useKeyPress훅을 직접 만들었지만, 사실 그럴 필요가 없었다. 이미 누군가 구현하여 공유하고 있기 때문이다.

이래는 Hook들이 공유되고 있는 웹 사이트이고 앱에 바로 적용할 수 있다.


위에서 구현했던 counter와 width예제를 hook으로 리펙토링 해 보자.

App의 로직들을 여러 조각으로 나누었다.

  • useCounter : count와 값을 수정할 수 있는 increment, decrement 메서드를 반환하는 hook
  • useWindowWidth : window의 현재 너비를 반환하는 hook
  • App : 상태가 있는 함수형 컴포넌트로 Counter와 Width 컴포넌트를 반환한다.

클래스 컴포넌트 대신 Hook을 사용하여 로직들을 작고 재사용가능한 조각으로 분리하였다.

변경 사항을 시각화해 이전과 비교해 보면 아래와 같다.

훅을 사용하여 컴포넌트를 훨씬 명확하고 작은 조각으로 분리하였다. 재사용하기도 훨씬 수월하다. 더 이상 상태를 위해 함수형 컴포넌트를 클래스형으로 변경하지 않아도 된다. ES2015의 클래스에 대한 지식은 크게 필요하지 않아졌다. 상태를 가지는 재사용 가능 로직은 테스트하기도 유연성도 가독성도 모두 높여주었다.

Additional Hooks Guidance

훅 추가하기

아래는 코드에 훅을 추가하고 싶을 때 참고할 수 있는 일반적인 훅에 대한 요약이다.

  1. useState: 함수형 컴포넌트 내에서 상태를 관리하려 할 때 클래스형 컴포넌트로 변경하지 않아도 이를 가능케 하는 훅이다. 다른 훅들에 비해 사용법이 단순하다
  2. useEffect: 코드를 컴포넌트의 생명 주기에 실행하기 위한 훅이다. 컴포넌트 함수 본문에서 변경을 하거나 뭔가를 구독하거나 타이머를 만들거나 로깅을 하는 등의 사이드 이펙트를 발생시키는것은 금지하고 있다. 허용될 경우 버그가 생기거나 모델과 뷰가 일치되지 않는 상황이 생길 수 있기 때문이다. useEffect는 이런 사이드 이펙트가 발생하는것을 예방하고 UI가 부드럽게 동작하도록 한다. componentDidMount, componentDidUpdate, componentWillUnmount를 합친 것과 유사하다
  3. useContext: 컨텍스트 객체를 받아서 provider에 넘겼던 값을 반환한다. React 의 Context API와 함께 동작하여 앱 전반적으로 공유하는 데이터를 prop drilling 없이 사용할 수 있게 해 준다.

주의해야 할 점은 useContext의 인자로는 컨텍스트 객체를 넘겨야 하고, useContext를 사용하는 컴포넌트는 컨텍스트 업데이트 시 마다 리렌더링 된다는 점이다. 4. useReducer: setState대신 사용할 수 있으며 특히 이전 상태에서 다음 상태로 변경될 때 복잡한 상태 로직과 파생되는 변수들을 만들어 내야 하는 경우 더 선호된다. reducer 함수와 초기 값을 인자로 받아 초기값과 dispatch함수를 반환한다. useReducer 는 또한 컴포넌트의 복잡한 상태에서 깊이 선언된 값을 변경할 때의 성능에 최적화되어 있다.

Hook의 장점과 단점

Hooks를 사용하며 얻는 장점들은 다음과 같다.

코드가 간결하다. 생명 주기와 얽히지 않으며 코드들을 관심사와 기능에 따라 분류할 수 있다. 이는 코드를 깔끔하게 할 뿐만 아니라 간결하면서도 짧게 유지할 수 있다. 아래는 상품 목록을 보여주는 컴포넌트에 대해 클래스와 함수형으로 구현한 예시이다.

class TweetSearchResults extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      filterText: '',
      inThisLocation: false,
    }

    this.handleFilterTextChange = this.handleFilterTextChange.bind(this)
    this.handleInThisLocationChange = this.handleInThisLocationChange.bind(this)
  }

  handleFilterTextChange(filterText) {
    this.setState({
      filterText: filterText,
    })
  }

  handleInThisLocationChange(inThisLocation) {
    this.setState({
      inThisLocation: inThisLocation,
    })
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inThisLocation={this.state.inThisLocation}
          onFilterTextChange={this.handleFilterTextChange}
          onInThisLocationChange={this.handleInThisLocationChange}
        />
        <TweetList
          tweets={this.props.tweets}
          filterText={this.state.filterText}
          inThisLocation={this.state.inThisLocation}
        />
      </div>
    )
  }
}
const TweetSearchResults = ({ tweets }) => {
  const [filterText, setFilterText] = useState('')
  const [inThisLocation, setInThisLocation] = useState(false)
  return (
    <div>
      <SearchBar
        filterText={filterText}
        inThisLocation={inThisLocation}
        setFilterText={setFilterText}
        setInThisLocation={setInThisLocation}
      />
      <TweetList
        tweets={tweets}
        filterText={filterText}
        inThisLocation={inThisLocation}
      />
    </div>
  )
}

복잡한 컴포넌트를 단순화 해 준다

자바스크립트의 클래스는 관리하기 힘들고 핫 리로딩과 함께 쓰기 어려우며 minifiy도 잘 되지 않는다. React의 Hook은 이런 문제들을 해결하고 함수형 프로그래밍을 쉽게 만들어주었다. Hook이 있으면 클래스 컴포넌트를 만들 필요가 없다.

상태 로직이 있는 자바스크립트의 클래스를 사용하는 것은 여러 단계의 상속 구현을 유도하고 전체적인 복잡도를 빠르게 증가시키며 에러를 발생하기 쉽게 만든다. 하지만 훅은 상태를 만드는 것 뿐만 아니라 React의 기능들을 클래스 없이 작성할 수 있다. React를 사용하면 상태를 가진 로직을 계속 재작성하지 않고 재사용할 수 있다. 에러를 발생시킬 확률을 낮추며 일반 함수를 조립해서 쓸 수 있게 해 준다.

훅이 구현되기 전에는 React에서 뷰가 없는 로직을 추출해내기가 어려웠다. 이는 HOC패턴이나 render prop패턴을 사용할 때 복잡도를 증가시키는 원인이 되었다. 하지만 훅이 추가되면서 상태가 있는 로직을 언제든 자바스크립트 함수로 분리할 수 있게 되었다.

훅도 주의해야할 몇 가지 단점들이 존재한다

  • 규칙에 따라 작성해야 한다. 정적 분석기 플러그인을 사용하지 않으면 어떤 훅이 규칙을 어기고 있는지 알기 어렵다
  • 올바르게 사용하기 위해 익숙하게 쓸 줄 알아야 한다
  • 잘못 쓸 수 있다 (예를 들어 useCallback, useMemo)

React Hooks vs Classes

훅이 React에 처음 소개되었을 때 새로운 이슈가 생겼었다. 클래스 컴포넌트와 함수형 컴포넌트+훅 조합을 어떤 기준에서 선택해야 할 까? 라는 이슈였다. 훅의 도움으로 함수형 컴포넌트에서도 상태와 일부 생명 주기 메서드를 사용할 수 있게 되었다. 훅은 클래스를 작성하지 않아도 로컬 상태와 그 외 다른 React의 기능을 이용할 수 있게 해 주었다.

아래는 클래스와 함수형+훅 조합 선택에 도움이 될 만한 차이점을 정리한 내용이다.

React HooksClasses
여러 계층을 만들게 되는것을 피할 수 있으며 코드가 훨씬 깔끔해진다일반적으로 HOC패턴이나 render props패턴 사용시 앱을 여러 계층으로 재구성해야 한다
컴포넌트들을 더 일관되도록 구현할 수 있다클래스는 사람이나 컴퓨터나 함수 호출시마다 바인딩 된 컨텍스트가 무엇인지 이해하기 어렵다