Hook

WooBuntu·2021년 3월 28일
0

알고 쓰자 리액트

목록 보기
3/11

https://ko.reactjs.org/docs/hooks-intro.html

useContext는 context, useRef는 DOM과 ref, useCallback과 useMemo 그리고 useReducer는 재조정 및 최적화에서 다룬다.

Hook의 등장배경

상태와 관련된 로직의 재사용

클래스형 컴포넌트에서는 컴포넌트 간에 로직을 재사용하기 위해 render propshigher order components 등의 패턴을 활용해왔다. 하지만 이런 패턴을 사용할 때 컴포넌트를 재구성해야 하고, provider, consumer, 고차 컴포넌트, render props 등등 각종 Wrapper가 중첩되는 문제가 발생한다.

Hook은 위와 같이 계층을 중첩하지 않고도 상태 관련 로직을 추상화하여 재사용하기 위한 도구이다.

Lifecycle method의 단점

Lifecycle method는 분리, 결합될 수 없기 때문에 서로 연관이 없는 상태와 관련된 로직들이 한 Lifecycle method에 묶여 있고, 한 상태와 관련된 로직들이 서로 다른 Lifecycle method에 분산되는 문제가 발생한다.
(이것이 컴포넌트를 더 작은 단위로 분해할 수 없는 이유이며, 별도의 상태 관리 라이브러리를 사용하게 되는 이유이다.)

Hook의 규칙

최상위에서만 Hook을 호출할 것

리액트가 여러 번의 Hook 호출을 허용하는 것은 각 Hook이 어떤 state와 대응되는지 알기 때문이다. 그리고 그것이 가능한 이유는 리액트가 모든 렌더링에서 Hook의 호출 순서를 동일하게 유지하기 때문이다. 반복문, 조건문, 중첩된 함수 내에서 Hook을 호출하면 안 되는 이유는 Hook의 호출 순서가 바뀌어 state와 올바르게 대응시킬 수 없기 때문인 것이다.

조건부로 effect함수를 실행해야 할 경우 조건문은 effect함수 내부에 설정해야 한다.

함수형 컴포넌트 내에서만 Hook을 호출할 것

일반 자바스크립트 함수에서는 Hook을 호출할 수 없다.
(예외적으로 custom Hook안에서도 호출 가능하다)

useState

import React, { useState } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  // const [state, setState] = useState(state 초기값);
  // ...
}

클래스형 컴포넌트의 state는 반드시 객체여야 하지만, Hook의 state는 값의 형태에 제한이 없다.

고비용 계산이 필요한 initial State

function Table(props) {
  const [rows, setRows] = useState(createRows(props.count));
  // ...
}

위와 같이 초기값에 함수의 호출을 전달하는 경우에는 렌더링을 할 때마다 createRows가 호출된다.

function Table(props) {
  const [rows, setRows] = useState(() => createRows(props.count));
  // ...
}

위와 같은 방식을 지연 초기화라고 하며, 이 경우 첫번째 렌더링 때만 createRows가 실행된다.

state update

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
  );
}
  • setState함수는 새 state값을 받아 컴포넌트 리렌더링을 큐에 등록한다.

  • 클래스형 컴포넌트의 this.setState는 얕은 병합의 방식으로 작동하지만, Hook의 setState는 새 state이 기존의 state를 덮어쓴다.(물론 얕은 병합을 하게끔 구현하는 것도 가능하다)

  • setState함수가 이전 state와 정확히 동일한 값을 반환한다면 리렌더링하지 않는다(Object.is 비교 알고리즘 사용)

useEffect

side effect

컴포넌트 안에서 데이터를 가져오거나 구독하고, DOM을 직접 조작하는 등의 작업은 다른 컴포넌트에 영향을 줄 수도 있고, 렌더링 과정에서는 구현할 수 없기 때문에 side effect라고 부른다.

useEffect는 이러한 side effect를 함수형 컴포넌트 안에서 수행할 수 있도록 해주는 도구로, 클래스형 컴포넌트의 componentDidMount, componentDidUpdate, componentWillUnmount를 하나의 API로 통합한 것이다.

clean-up하지 않는 Effects

네트워크 요청, DOM 수동 조작, 로깅 등은 clean-up이 필요하지 않은 side-effect이다.

clean-up하는 Effects

외부 데이터에 구독을 설정하는 effect의 경우 구독을 해제해주지 않으면 메모리 누수가 발생한다.

실행 시점

componentDidMount와 componentDidUpdate와는 달리 useEffect함수는 layout과 paint가 끝난 시점에 clean-up함수->effect함수 순으로 실행된다. 또한, useEffect내부의 함수 실행이 완료되기 전까지는 다음 렌더링을 진행하지 않는다.

다만, DOM변경과 같이 DOM갱신이 완료되기 전에 동기적으로 실행되어야 할 effect들도 있다. 이렇듯 동기적으로 실행되어야 할 effect의 경우 useLayoutEffect를 사용해야 한다.

// { friend: { id: 100 } } state을 사용하여 마운트합니다.
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // 첫번째 effect가 작동합니다.

// { friend: { id: 200 } } state로 업데이트합니다.
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 이전의 effect를 정리(clean-up)합니다.
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // 다음 effect가 작동합니다.

// { friend: { id: 300 } } state로 업데이트합니다.
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 이전의 effect를 정리(clean-up)합니다.
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // 다음 effect가 작동합니다.

// 마운트를 해제합니다.
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 마지막 effect를 정리(clean-up)합니다.

실행 조건

useEffect의 실행 조건은 dependency에 달려 있다.

dependency를 활용한다면 effect함수와 clean-up함수 내에서 클로저로 참조하는 state와 props는 모두 포함시켜주어야 한다. 그렇지 않다면 해당 값은 갱신값을 반영하지 못하기 때문이다. 이를 지키기 위해서 보통 useEffect내부에서 사용될 함수는 useEffect내부에서 선언하여 필요한 값들을 확인하기 쉽게끔 한다.

dependency를 전달하지 않는 경우

항상 최신의 state와 props를 참조하기 위해 매 렌더링마다 실행된다. useEffect내부에 함수 표현식을 전달한 경우 useEffect가 실행될 때마다 새로 함수가 생성되어 최신 props와 state를 클로저로 참조할 수 있기 때문이다.

dependency로 빈 배열을 전달하는 경우

의존하는 값이 없다는 의미로 마운트 시점에 effect함수를 1번, 언마운트 시점에 clean-up함수를 1번 실행한다.

useEffect안에서 참조하는 state와 props는 항상 마운트 시점의 초기값을 유지한다. (마운트 시점에만 effect함수와 clean-up함수가 생성되어 그 시점의 클로저를 가지기 때문)

dependency에 특정 state나 props를 채워 전달하는 경우

useEffect내부에서 특정 state나 props를 참조할 경우 해당 값들의 최신 값을 반영하기 위함이다. 따라서 dependency배열의 원소 중 어느 하나라도 이전 렌더링의 값과 다르다면 useEffect가 실행된다.

dependency의 갱신이 너무 잦은 경우

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 이 effect는 'count' state에 따라 다릅니다
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

  return <h1>{count}</h1>;
}

상기의 예제에서는 effect함수에서 count state를 참조하고 있기 때문에 dependency로 count를 전달해주었다. 하지만, 이 경우 count값이 바뀔 때마다 useEffect가 새로 실행되어 1초마다 clearInterval해주고 새로운 setInterval를 설정해주게 된다. 이러한 문제를 해결하는 방법은 두 가지가 있다.

1. '함수적 갱신'

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); 
    }, 1000);
    return () => clearInterval(id);
  }, []); 

  return <h1>{count}</h1>;
}

'함수적 갱신'을 사용하면 count state를 클로저로 참조할 필요가 없고, 따라서 useEffect도 count를 dependency로 가질 필요가 없다. 결국 setInterval는 컴포넌트 마운트 시점에 단 1번, clearInterval는 컴포넌트 언마운트 시점에 단 1번 실행된다.

2. useReducer

useReducer의 경우 dispatch로 state의 업데이트를 트리거한다. reducer함수 내부에서 state를 참조할 수 있기 때문에 dispatch의 인자로는 type과 payload등 dependency와는 무관한 값만을 전달하여 useEffect의 dependency를 빈 배열로 설정해주어도 된다.

useLayoutEffect

말그대로 브라우저의 layout이 끝나고 paint하기 이전 시점에 동기적으로 실행되는 Hook이다. 즉, useLayoutEffect가 다 끝난 후에야 paint가 된다는 것.
(componentDidMount와 componentDidUpdate도 이 시점에 실행된다)

SSR을 하는 경우라면 자바스크립트가 전부 다운로드 되기 전까지는 useEffect와 useLayoutEffect 모두 실행되지 않는다.
(아마 브라우저가 자바스크립트 실행 이후 렌더링 트리 생성을 완료한 후에 layout과 paint작업이 실행되기 때문인 것 같다)
(이 부분은 브라우저 렌더링에 대해 좀 더 공부한 뒤 보충 필요...)

SSR을 할 때 자바스크립트 코드가 실행(hydration)되기 전에 깨져 보일 수 있는 UI를 표시하지 않으려면 useEffect(() => { setShowChild(true); }, [])와 showChild && 의 조합으로 layout effect가 필요한 컴포넌트를 배제시킬 수 있다.
(무슨 소릴까)

useDebugValue

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  // ...

  // Show a label in DevTools next to this Hook
  // e.g. "FriendStatus: Online"
  useDebugValue(isOnline ? 'Online' : 'Offline');

  return isOnline;
}

개발자도구에서 custom Hook의 label을 표시하기 위한 hook으로, 해당 custom hook이 공유되는 라이브러리일 때 유용하다.

또한 디스플레이 값을 포맷팅하는 연산의 비용이 큰 경우도 있는데, 이럴 때는 useDebugValue의 두 번째 인자로 포맷팅 함수를 전달할 수 있다.

이 함수는 custom hook이 감지되었을 때만 호출된다.

useDebugValue(date, date => date.toDateString());
// custom hook이 감지되었을 때만 toDateString을 호출한다.

Custom Hook

상태 관련 로직을 컴포넌트 간에 재사용하고 싶을 때 사용하는 도구이다.

custom Hook은 그 자체가 state인 것이 아니다. 각 컴포넌트에서 호출되는 custom Hook은 서로 독립된 state를 가지는 것이다.

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

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

클래스형 컴포넌트와 함수형 컴포넌트의 차이

  • 인스턴스를 생성하고, constructor내부에서 이벤트 핸들러를 바인딩하는 등 클래스에서 발생하는 overhead가 hook에서는 발생하지 않는다.

  • render props, context, 고차 컴포넌트 등이 유발하는 컴포넌트 트리의 깊은 중첩이 hook의 관용 코드에서는 필요하지 않다.
    (컴포넌트 트리가 작을수록 리액트의 부담이 줄어든다)

  • 클래스형 컴포넌트가 shouldComponentUpdate에서 수행했던 최적화를 hook은 다음과 같이 수행한다.

    • useCallback으로 감싼 콜백을 자식 컴포넌트로 전달하여 참조 비교를 수행한다.
    • useMemo를 통해 개별 자식들의 렌더링을 최적화한다.
    • context와 useReducer를 사용하면 콜백을 props로 깊이 전달할 필요가 없다.

Hook의 내부 작동 방식

각 컴포넌트는 자신만의 'memory cell'(일반 자바스크립트 객체)의 리스트를 가지고 있다. 각각의 hook호출은 자신과 대응되는 memory cell을 읽어들이고 pointer를 통해 다음 memory cell로 이동하는 방식으로 작동한다.

이것이 hook을 조건문, 반복문, 중첩된 함수 안에서 호출할 수 없는 이유이며, 리액트가 각각의 hook호출을 대응되는 state와 연결시킬 수 있는 이유이다.

0개의 댓글