Using the Effect Hook

윤석주·2022년 5월 24일
5

React

목록 보기
9/13

Hooks는 Class없이 상태를 관리할 수 있도록 도와준다.

Effect hooks는 함수형 컴포넌트 내부에서 side effect를 실행하도록 도와줍니다.

import React, { useState, useEffect } from 'react';

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

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

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

대표적인 "side effect"의 예시로 Data fetching, setting up a subscription, manually changing the DOM 등이 있습니다.

React class에 익숙하다면 useEffect를 componentDidMount, componentDidUpdate, componentWillUnmount의 조합으로 볼 수 있습니다.

side effect는 clean-up의 필요 여부에 따라 크게 2가지로 나뉠 수 있습니다. (clean-up이 필요한 effect, 필요 없는 effect)

Effects Without Cleanup

리액트가 DOM을 업데이트한 후 추가적인 코드를 실행시켜야 하는 상황이 있습니다. Network request, manual DOM mutations, logging 등이 clean-up이 필요하지 않는 상황의 대표적인 예시죠. 이런 side effects를 hooks와 class가 어떻게 표현하는지 비교해보겠습니다.

Example Using Classes

리액트 컴포넌트에서는 render 메서드 자체가 side effect를 발생시켜선 안됩니다. 우리는 우리의 부수효과가 리액트가 DOM을 업데이트 시킨 이후에 발생하길 원하죠.

그래서 class형 컴포넌트에선 componentDidMount, componentDidUpdate안에 부수효과를 넣습니다. 위에서 살펴본 예시를 적용한 코드는 다음과 같습니다.

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

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

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

두 개의 lifecycle method에 중복된 코드가 있는 것을 눈치채셨나요?

많은 경우 부수효과가 mount, update 여부에 상관 없이 매번의 render 마다 필요한 경우가 많기 때문에 위와 같은 코드 패턴이 나타나게 됩니다. 하지만 React class는 그런 메소드를 가지고 있지 않습니다. 그래서 중복된 코드를 2번 작성해야 하죠.

이번엔 useEffect Hook을 살펴보겠습니다.

Example Using Hooks

import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

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

What does useEffect do? - 이 hook은 React에게 매 렌더링 마다 실행이 필요한 코드를 전달합니다. React는 우리가 건낸 effect(함수)를 기억하고, DOM update 이후 호출합니다. 이렇게 전달하는 effect안에서 우리는 document title을 변화시키던지, data fetching을 한다던지, 다른 필요한 API를 호출할 수 있습니다.

Why is useEffect called inside a component? - useEffect를 컴포넌트 내부에 위치시키는 것으로 인해 count state, props등에 바로 접근이 가능합니다. 이러한 변수들은 함수 스코프 내에 위치하므로 접근을 위한 별도의 api를 필요로 하지 않습니다. Hooks는 자바스크립트의 closure를 포용하여 동작하도록 설계되어 추가적인 api를 필요로 하지 않습니다.

Does useEffect run after every render? - default로 그렇습니다. 기본적으로 useEffect는 mount, update시 매 번 동작하도록 설정되어 있습니다(물론 이런 설계는 customize 가능합니다). mount, update를 의식하기 보단 "after render" 마다 effect가 발생하는 것으로 인식하는게 편합니다. React는 effect가 불리기 전 DOM update가 되었음을 보장합니다.

Example Using Hooks

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
}

경험 많은 자바스크립트 개발자는 useEffect에 전달하는 함수가 매 render마다 다르다는 것을 알고 있을겁니다. 이것으로 인해 useEffect내의 함수가 접근하는 count가 정확한 값을 가지도록 보장합니다. render가 일어날 때 마다 우리는 다른 effect를 스케쥴하여 이전 effect를 대체합니다.

componentDidMount, componentDidUpdate와 달리 useEffect는 browser가 화면을 업데이트 하는 것을 blocking하지 않습니다. 그리고 이건 좋은 사용자 경험으로 이어집니다. effect가 반드시 동기적으로 이뤄질 필요가 없기 때문이죠.

Effect with Cleanup

위에서 우리는 cleanup이 필요하지 않은 effect를 살펴봤습니다. 하지만 어떤 effect는 필요합니다. 예를 들면 구독과 구독취소 등의 동작이 필요한 경우가 있습니다.

Example Using Classes

React Class에선 componentDidMount시 구독을 하고, componentWillUnmount시 구독 취소가 필요합니다. ChatAPI라는 API가 친구의 상태에 따른 구독/구독취소 기능을 제공한다고 가정해보겠습니다.

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

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

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

ComponentDidMount, ComponentWillUnmount가 서로를 필요로 하는 것이 보이나요? Lifecycle 메소드는 코드를 나누도록 강제하기에 연관된 effect도 이렇게 나뉘게 되는 것이죠.

Example Using Hooks

cleanup을 위해 분리된 효과가 있어야 한다고 생각할 수 있습니다. 하지만 구독/구독취소는 강하게 연관되어 있습니다. useEffect는 이러한 연관성을 유지할 수 있도록 설계되어 있습니다. 우리가 전달하는 효과(함수)에서 함수를 리턴한다면, React는 cleanup을 위해 그 함수를 사용합니다.

import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

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

Why did we return a function fro our effect? - effect에 대한 선택적 cleanup 메커니즘을 제공합니다. 매 이팩트마다 cleanup시 리턴되는 함수가 실행됩니다. 같은 부수효과를 담당하는 기능을 코드상으로 가깝게 유지할 수 있도록 도와줍니다.

When exactly does React clean up an effect? - 컴포넌트가 unmount되는 경우 입니다. 하지만, 위에서 본 것 처럼 effects는 매 render마다 실행됩니다. 이것이 리액트가 cleanup을 매 render마다 실행시키는 이유입니다. 즉, unmount/update시 마다 cleanup을 진행합니다.

Tip: Use Multiple Effects to Separate Concerns

class 컴포넌트에서 여러 부수효과를 다루는 코드를 살펴보겠습니다.

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
    });
  }
  // ...

document.title과 ChatAPI가 서로 다른 lifecycle methods에 분리되어 작성된 것을 확인할 수 있습니다. 연관이 깊은 로직이지만, 서로 분리되고 중복되어 존재하죠.

Hooks는 이런 문제를 "부수효과별로" 처리할 수 있도록 도와줍니다.

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

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

Hooks는 코드의 역할에 따라 나눌 수 있도록 도와줍니다. React는 모든 부수효과를 명시된 순서대로 전부 실행합니다.

Explanation: Why Effects Run on Each Update

class에 익숙한 개발자들은 왜 부수효과의 cleanup phase가 unmount가 아니라 매 re-render시 동작하는지 의문이 생길 수 있습니다. practical example을 보며 이런 디자인이 버그를 줄여주는 이유를 살펴보겠습니다.

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

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

만약 컴포넌트가 화면에 표시되어 있을때 friend prop이 변한다면 어떻게 될까요? 컴포넌트는 계속해서 다른 친구의 online status를 표시하고 있을 겁니다. 이것은 큰 버그죠. 또한 unmounting시 구독취소에서 올바르지 않은 ID를 이용하므로 memory leak이나 crash를 발생시킬 수 있습니다.

class 컴포넌트에선 componentDidUpdate를 이용해 이러한 문제를 해결하죠.

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

  componentDidUpdate(prevProps) {
    // Unsubscribe from the previous friend.id
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange
    );
    // Subscribe to the next friend.id
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

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

물론 위의 코드도 완벽하지 않지만, 지금은 lifecycle methods 별로 중복되는 코드만을 확인해주세요.

이제 hooks를 사용하는 코드를 확인해보겠습니다.

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

일반적인 hooks 형태는 위의 버그로부터 자유롭습니다.

useEffect는 기본적으로 update시의 핸들링을 포함하기에 추가적인 코드가 필요하지 않습니다. 지난 효과를 cleanup하고 다음 효과를 적용하도록 설계가 되어있기 때문이죠.

이런 동작은 기본적으로 일관성을 보장해주며 update logic에서 생기는 버그를 방지해줍니다.

Tip: Optimizing Performance by Skipping Effects

몇몇 상황에서 cleanup을 매 랜더링마다 일어나도록 하는 것은 성능적으로 문제가 있을 수 있습니다. class 컴포넌트에선 이런 문제를 extra comparison을 이용해 해결하죠

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

useEffect Hook API는 이러한 요구사항을 해결할 수 있도록 설계되어있습니다. React에게 랜더링마다 특정 값이 변하지 않으면 특정 효과를 실행하지 않도록 설정할 수 있죠. useEffect의 두 번째 인자로 배열을 넘겨주어 이런 요구사항을 적용할 수 있습니다.

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

위의 예시에서 우리는 [count] 배열을 넘겨주었습니다. 위 부수효과는 count변수가 변하지 않으면 실행되지 않도록 도와줍니다. 즉, 변경이 필요한 경우에만 effect가 실행되도록 도와주죠. 이것이 리액트팀의 최적화입니다.

물론 이러한 최적화는 cleanup phase에도 적용됩니다.

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

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes

미래에는 두 번째 파라미터를 자동으로 생성해주는 업데이트도 예정 되어 있다고 합니다.

이런 최적화를 사용하는 경우 두 번째 배열이 컴포넌트 스코프내에서 이팩트와 연관된 변화하는 모든 필요한 값을 포함하도록 해야 합니다. 그렇지 않으면 코드는 잘못된 값을 랜더링하게 됩니다.

만약 최초mount/unmount 에만 효과가 필요하다면 두 번째 인자로 빈 배열([])을 넣어줄 수 있습니다. 이것은 리액트에게 리랜더링시 실행될 필요가 없다고 명시해줍니다.

State Hook과 Effect Hook을 결합하여 사용하면 class 컴포넌트를 사용할 때의 대부분의 useCase를 커버할 수 있습니다. 일부 커버하지 못하는 부분에 대하여 additional Hooks를 나중에 더 배워보겠습니다.

또한 Hooks는 effect cleanup이 중복을 피하고 연관된 코드를 가깝게 유지해주며 버그를 피할 수 있다는 사실을 확인했습니다. 그리고 effect를 목적에 맞게 분리하여 관리하는 방법도 살펴봤습니다(using multiple useEffect).

이제 Hooks동작 원리에 대해 알아봐야 할 시점입니다. React는 어떻게 랜더링마다 매칭되는 useState, state variable을 구별할 수 있을까요? React가 어떻게 이전과 다음 효과를 매 랜더링마다 "match up" 할까요?

다음 포스팅에선 Hooks가 동작하는 핵심인 Rules of Hooks를 살펴보겠습니다.

출처

profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글