React Hooks

불꽃남자·2020년 10월 20일
0

Hooks란 무엇인가?

React는 본래 함수형 컴포넌트 방식과 클래스형 컴포넌트 방식 둘 다 지원했었으나, State나 Life Cycle같은 기능은 클래스형 컴포넌트에서만 지원했기 때문에 사실상 클래스형 컴포넌트 방식이 강제됐었다.
그러다 React 16.8버전부터 React Hooks라는 것이 등장한다. 공식 문서에 의하면, Hook은 함수 컴포넌트에서 React state와 생명주기 기능(lifecycle features)을 “연동(hook into)“할 수 있게 해주는 함수라고 정의된다. 간단히 말하면 함수형 컴포넌트에서도 State와 Life Cycle과 같은 기능들을 사용할 수 있게 해주는 함수이다.

Hooks는 왜 사용되는가?

내가 React를 배우기 시작했을 때에는 이미 Hooks가 나온지 2년이 지난 시점이여서, 나는 클래스형 컴포넌트를 심도깊게 배우고 사용해본 적이 없으나, Hooks가 등장한 이유는 클래스형 컴포넌트를 사용하는 개발자들이 불편함을 느꼈기 때문이다. 공식 문서에서는 Hooks를 개발하게 된 이유 세 가지를 서술하고 있다.

  1. 클래스형 컴포넌트 사이에서 상태와 관련된 로직을 재사용하기 어렵다.
  2. 복잡한 컴포넌트들은 이해하기 어렵다.
  3. Class는 사람과 기계를 혼동시킨다.

또한 React 공식 문서에 의하면 클래스형 컴포넌트는 미래에도 계속 지원할 계획이나, Hooks를 사용하는 것을 강력히 권유하고 있다. 이것이 내가 클래스형 컴포넌트에 대해 깊게 배우지 않은 이유이기도 하다.

주로 사용되는 Hook

React는 11개 정도의 내장 Hooks를 지원하고 있으며 React 공식 문서는 그 중 크게 두 가지 Hook을 소개하고 있다. 첫 번째로 State Hook, 두 번째로 Effect Hook이다. 아마 이 둘이 가장 중요하다고 생각되어지는 Hook이기 때문에 이들만 따로 공식문서에 설명 페이지가 있는듯 하다.

State Hook

State Hook은 클래스형 컴포넌트의 this.state와 this.setState 기능을 함수형 컴포넌트에서도 사용할 수 있게 해주는 함수이다. 다음 코드를 살펴보자.

const Counter = () => {
  const [count, setCount] = useState(0);
  const onIncrease = () => { setCount(count + 1) };
  
  return (
    <>
      <span>{count}</span>
      <button onClick={onIncrease}>증가</button>
    </>
  );
};

useState함수가 State Hook함수이다. useState함수는 인자로 initialState를 받고, state와 state를 갱신할 수 있는 함수를 배열으로써 반환한다. 그렇기 때문에 [count, setCount]와 같이 배열구조분해 문법을 사용하여 반환된 값을 받아온다.
그리고 <span>{count}</span>와 같이 state값을 사용할 수 있다. 클래스형 컴포넌트에서는 state가 constructor안에 있기 때문에 {this.state.count}와 같이 사용해야 한다. 함수형 컴포넌트는 이를 좀 더 직관적으로 사용할 수 있다.
그 밑에는 onCilck 핸들러로 onIncrease함수를 가지고 있는 button이 있다. onIncrease함수는 호출되면 setCount함수를 호출해 count의 값을 1 늘려준다. setCount(count+1)이라는 간단한 형태로 state를 갱신할 수 있다. 클래스형 컴포넌트라면 this.setState({ count: this.state.count+1 })와 같은 형태로 state를 갱신해야한다. 단순히 코드의 양이 많다는 점에서부터 벌써 피곤하다.

또한 클래스형 컴포넌트는 state를 객체 형태로 밖에 선언하지 못 하며, state 객체 안에서 해당 컴포넌트의 모든 state를 선언해야한다. 그에 반하여 useState Hook은 어떠한 형태의 데이터라도 state로 선언할 수 있으며, 원한다면 useState Hook을 여러번 호출할 수도 있다. 내 생각에 이 부분이 State와 관련된 로직의 재사용성을 높여주는 것 같다.

useEffect Hook

공식 문서에서는 Effect Hook은 함수형 컴포넌트의 Side Effect를 수행하는 Hook이라고 소개하고 있다. 또한 클래스형 컴포넌트의 Life Cycle중 componentDidMount와 componentDidUpdate, componentWillUnmount가 합쳐진 것으로 생각해도 좋다고 덧붙여 설명하고 있다. 물론 이 세 가지 Life Cycle은 Side Effect가 허용된다.

솔직히 Side Effect가 무엇인지 이 글을 작성하기 전에 잘 몰랐다. 사실 지금도 잘 모르겠다. 가까운 시일 내에 Side Effect에 대해 포스팅을 하려고 한다.

공식 문서는 Side Effect에 대한 예로 외부 API등으로 데이터를 가져오거나 스토어에서 데이터를 구독하는 일, DOM객체를 조작하는 일 등을 들고 있다.

다음의 코드는 React 공식 문서에 나와 있는 useEffect 예시 코드이다.

function Example() {
  const [count, setCount] = useState(0);

  // componentDidMount, componentDidUpdate와 같은 방식으로
  useEffect(() => {
    // 브라우저 API를 이용하여 문서 타이틀을 업데이트합니다.
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffect의 인자로 익명함수를 주었고, 익명 함수 내부에서 document.title을 갱신하고 있다. 이 익명 함수는 해당 컴포넌트의 매 랜더링이 완료되는 시점에 실행된다. 그렇기 때문에 DOM 객체에도 접근할 수 있는 것이다. 매 랜더링 시마다 실행된다는 점에서 클래스 컴포넌트의 componentDidMount와 componentDidUpdate가 합쳐진 것이라고 볼 수 있다.

또한 Effect Hook은 Clean-Up 기능을 제공한다. 다시 예시 코드를 보자.

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // effect 이후에 어떻게 정리(clean-up)할 것인지 표시합니다.
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';

useEffect의 콜백함수가 반환하는 함수가 바로 clean-up시에 작동시킬 함수가 된다. 클래스 컴포넌트의 componentWillUnmount와 비슷하나, 분명히 다르다. componentWillUnmount는 해당 컴포넌트가 DOM에서 제거될 때에 실행된다. 하지만 useEffect는 어떤가? 해당 컴포넌트가 리렌더링 될 때마다 실행된다. 공식문서가 설명하길 이것은 의도된 것이며, 버그를 줄이기 위함이라고 한다. 아래의 코드를 살펴보자.

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  // ...

props의 id를 인자로 받아 해당 id를 가진 firend의 online상태를 갱신하는 API를 componentDidMount에서 구독하고, componentWillUnmount에서 구독해제하는 컴포넌트가 있다. 그런데 컴포넌트가 화면에 랜더링되는 중에 id props가 바뀌었다면? 컴포넌트는 리렌더링 되지만 여전히 리렌더링 되기 전의 id를 가진 firend 데이터를 구독 할 것이다. 왜냐하면 Mount되고 해제될 때의 Life Cycle 메서드만 설정했지 Rendering 될 때의 Life Cycle 메서드는 설정하지 않았기 때문이다. id props가 바뀔 때마다 해당 id의 friend를 구독하고, 이전 id의 firend 구독을 해제하려면 다음과 같이 Life Cycle 메서드가 설정되어야 한다.

 componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate(prevProps) {
    // 이전 friend.id에서 구독을 해지합니다.
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange
    );
    // 다음 friend.id를 구독합니다.
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

componentDidUpdate는 컴포넌트가 리렌더링 될 때마다 실행된다. 그러므로 이 컴포넌트는 id props가 바뀌면 리렌더링 되면서 이전 friend의 구독을 해제하고 현재 props의 id를 가진 friend를 구독하게 된다.

다시 Effect Hook으로 돌아가서, Effect Hook의 Clean-Up 함수와 ComponentWillUnmount의 다른 점을 알겠는가? Effect Hook의 Clean-Up 함수는 컴포넌트가 리렌더링 되기 직전에 실행된다. 위의 Life Cycle들을 Effect Hook으로 나타내면 다음과 같다.

useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

컴포넌트가 렌더링되면 firend를 구독하고, 리렌더링되기 직전에 구독을 해제한다. 단순히 코드의 양이 줄었을 뿐만 아니라 이 모든게 useEffect함수 안에서 일어났기 때문에 로직의 재사용성이나 가독성등이 좋아졌다.
클래스형 컴포넌트의 Life Cycle에서는 여러가지 Life Cycle 메서드로 나뉘어져 이 작업을 했기 때문에 로직을 재사용하려면 머리를 좀 싸매야 할 것이다.

나의 결론

이 글을 쓰며 Hook이 등장하게 된 계기, 클래스형 컴포넌트의 단점 등을 알아보았다. 나는 더더욱 클래스형 컴포넌트를 사용할 이유가 없다고 느꼈으며, Hook이나 열심히 배워야겠다고 생각했다. 한편으로는 또 언젠가에는 개발자들이 Hooks도 불편하다며 새로운 더 좋은 것을 만들어 낼 것인가 기대도 된다.

참고한 사이트

React 공식 문서

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글