장경은
Study

[3장] 리액트 훅 깊게 살펴보기

리액트의 모든 훅 파헤치기

useState

  • 리액트의 렌더링은 함수 컴포넌트에서 반환한 결과물인 return 값을 비교해 실행되기 때문에 state가 아닌 변수가 변경되어도 렌더링이 일어나지 않는다.
  • 매번 렌더링이 발생될 때마다 함수는 다시 새롭게 실행되고, 새롭게 실행되는 함수에서 state는 매번 선언 시 값으로 초기화된다.
  • useState는 클로저를 이용했다.
  • useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initialization)라고 한다.
    • 이렇게 하면 컴포넌트가 처음 구동될 때만 실행되고, 이후 리렌더링 시에는 실행되지 않는다.
    • localStorage나 sessionStorage에 대한 접근, map, filter, find 같은 배열에 대한 접근, 혹은 초깃값 계산을 위해 함수 호출이 필요할 때와 같이 무거운 연산을 포함해 실행 비용이 많이 드는 경우에 게으른 초기화를 사용하는 것이 좋다.

useEffect

  • useEffect는 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘이다.
    • 그리고 이 부수효과가 '언제' 일어나는지보다 어떤 상태값과 함께 실행되는지 살펴보는 것이 중요하다.
  • useEffect는 자바스크립트의 proxy나 데이터 바인딩, 옵저버 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니고 렌더링할 때마다 의존성에 있는 값을 보면서 이 의존성의 값이 이전과 다른 게 하나라도 있으면 부수 효과를 실행하는 평범한 함수이다.
  • useEffect 내에서 반환되는 함수를 클린업 함수라고 한다.
    • 클린업 함수는 이전 counter 값, 즉 이전 state를 참조해 실행된다.
    • useEffect는 그 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행한다.
    • 클린업 함수는 생명주기 메서드의 언마운트 개념과는 조금 차이가 있다.
    • 이는 말 그대로 이전 상태를 청소해주는 개념으로 보는 것이 옳다.
  • 의존성 배열에 빈 배열을 둔다면 리액트가 이 useEffect는 비교할 의존성이 없다고 판단해 최초 렌더링 직후에 실행된 다음부터는 더 이상 실행되지 않는다.
  • 의존성 배열에 아무런 값도 넘겨주지 않는다면 이때는 의존성을 비교할 필요 없이 렌더링할 때마다 실행이 필요하다고 판단해 렌더링이 발생할 때마다 실행된다.
  • 의존성 배열이 없는 useEffect가 매 렌더링마다 실행된다면 그냥 useEffect 없이 써도 되는게 아닐까?
    • 서버 사이드 렌더링 관점에서 useEffect는 클라이언트 사이드에서 실행되는 것을 보장해 준다. 따라서 useEffect 내부에서는 window 객체의 접근에 의존하는 코드를 사용해도 된다.
    • useEffect는 컴포넌트 렌더링의 부수 효과, 즉 컴포넌트의 렌더링이 완료된 이후에 실행된다.
    • react는 useEffect 의존성 배열의 이전 값과 현재 값을 얕은 비교한다.
  • eslint-disable-line react-hooks/exhaustive-deps 주석은 최대한 자제하라.
    • 의존성 배열을 넘기지 않은 채 콜백 함수 내부에서 특정 값을 사용한다는 것은, 이 부수 효과가 실제로 관찰해서 실행돼야 하는 값과는 별개로 작동한다는 것을 의미한다.
    • 즉, 컴포넌트의 state, props와 같은 어떤 값의 변경과 useEffect의 부수 효과가 별개로 작동하게 된다.
    • 만약 특정 값을 사용하지만 해당 값의 변경 시점을 피할 목적이라면 메모이제이션을 적절히 활용해 해당 값의 변화를 막거나 적당한 실행 위치를 다시 한번 고민해 보는 것이 좋다.
  • 익명 함수가 아닌 적절한 이름을 사용한 기명 함수로 바꾸는 것이 좋다.
  • 거대한 useEffect를 만들지 마라
    • 가능한 한 useEffect는 간결하고 가볍게 유지하는 것이 좋다.
    • 큰 useEffect를 만들어야 한다면 적은 의존생 배열을 사용하는 여러 개의 useEffect로 분리하는 것이 좋다.
    • 만약 의존성 배열에 불가피하게 여러 변수가 들어가야 하는 상황이라면 최대한 useCallback과 useMemo 등으로 사전에 정제한 내용들만 useEffect에 담아두는 것이 좋다.
  • useEffect 내에서 사용할 부수 효과라면 내부에서 만들어서 정의해서 사용하는 편이 훨씬 도움이 된다.
  • 왜 useEffect의 콜백 인수로 비동기 함수를 바로 넣을 수 없을까?
    • 만약 useEffect의 인수로 비동기 함수가 사용 가능하다면 비동기 함수의 응답 속도에 따라 결과가 이상하게 나타날 수 있다.
    • 이전 state 기반의 응답이 10초가 걸렸고, 이후 바뀐 state 기반의 응답이 1초 뒤에 왔다면 이전 state 기반으로 결과가 나와버리는 불상사가 생길 수 있다. 이러한 문제를 useEffect의 경쟁 상태(race condition)라고 한다.
    • useEffect 내부에서 비동기 함수를 선언해 실행하거나, 즉시 실행 비동기 함수를 만들어서 사용하는 것은 가능하다.
    • 다만 비동기 함수가 내부에 존재하게 되면 useEffect 내부에서 비동기 함수가 생성되고 실행되는 것을 반복하므로 클린업 함수에서 이전 비동기 함수에 대한 처리를 추가하는 것이 좋다.

useMemo

  • useMemo는 렌더링 발생 시 의존성 배열의 값이 변경되지 않았으면 함수를 재실행하지 않고 이전에 기억해 둔 해당 값을 반환하고, 의존성 배열의 값이 변경됐다면 첫 번째 인수의 함수를 실행한 후에 그 값을 반환하고 그 값을 다시 기억해 둔다.
  • useMemo로 컴포넌트도 감쌀 수 있다. 물론 React.memo를 쓰는 것이 더 현명하다.
  • MemoizedComponent는 의존성으로 선언된 value가 변경되지 않는 한 다시 계산되는 일은 없을 것이다. 메모이제이션을 활용하면 무거운 연산을 다시 수행하는 것을 막을 수 있다는 장점이 있다.

useCallback

  • useMemo가 값을 기억했다면, useCallback은 인수로 넘겨받은 콜백 자체를 기억한다.
  • 값의 메모이제이션을 위해 useMemo를 사용했다면, 함수의 메모이제이션을 위해 사용하는 것이 useCallback이다.
  • useCallback은 useMemo를 사용하여 구현할 수 있다.

useRef

  • useRef는 그 값이 변하더라도 렌더링을 발생시키지 않는다.
  • useRef를 사용하는 것과 함수 외부에서 값을 변수로 선언해서 관리하는 것의 차이
    • 함수 외부에서 변수를 선언하는 것은 컴포넌트가 실행되어 렌더링되지 않았음에도 해당 값이 기본적으로 존재하게 된다. 이는 메모리에 불필요한 값을 갖게 하는 악영향을 미친다.
    • 또한 컴포넌트가 여러 번 생성된다면 각 컴포넌트에서 가리키는 값이 모두 동일하다.
    • 컴포넌트 인스턴스 하나당 하나의 값을 필요로 하는 것이 일반적이다.
  • useRef로 DOM에 접근할 때 useRef가 선언된 당시에는 아직 컴포넌트가 렌더링되기 전이라 return으로 컴포넌트의 DOM이 반환되기 전이므로 undefined이다.
    • 이 경우 useEffect 내에서 useRef를 사용하면 해당 DOM에 접근할 수 있다.
  • useMemo로 useRef를 구현할 수 있다.

useContext

  • 콘텍스트를 사용하면, 이러한 명시적인 props 전달 없이도 선언한 하위 컴포넌트 모두에서 자유롭게 원하는 값을 사용할 수 있다.
  • 만약 여러 개의 Provider가 있다면 가장 가까운 Provider의 값을 가져오게 된다.
  • Root 컴포넌트에 모든 context를 넣는다면 콘텍스트가 많아질수록 루트 컴포넌트는 더 많은 콘텍스트로 둘러싸일 것이고 해당 props를 다수의 컴포넌트에서 사용할 수 있게끔 해야 하므로 불필요하게 리소스가 낭비된다.
  • 일부 리액트 개발자들이 콘텍스트와 useContext를 상태 관리를 위한 리액트의 API로 오해하고 있다. 엄밀히 따지면 콘텍스트는 상태를 주입해 주는 API다.
    • 단순히 props 값을 하위로 전달해 줄 뿐, useContext를 사용한다고 해서 렌더링이 최적화되지는 않는다.

useReducer

  • useReducer는 useState의 심화 버전으로 볼 수 있다.
  • useReducer의 목적
    • 복잡한 형태의 state를 사전에 정의된 dispatcher로만 수정할 수 있게 만들어 줌으로써 state 값에 대한 접근은 컴포넌트에서만 가능하게 하고, 이를 업데이트하는 방법에 대한 상세 정의는 컴포넌트 밖에다 둔 다음, state의 업데이트를 미리 정의해 둔 dispatcher로만 제한하는 것이다.
  • useReducer나 useState 둘 다 세부 작동과 쓰임에만 차이가 있을 뿐, 결국 클로저를 활용해 값을 가둬서 state를 관리한다는 사실에는 변함이 없다.

useImperativeHandle

  • forwardRef
    • ref는 props로 쓸 수 없다.
    • 예약어로 지정된 ref 대신 다른 이름으로 props를 받으면 사용은 가능하다.
    • ref를 받고자 하는 컴포넌트를 forwardRef로 감싸고, 두 번째 인수로 ref를 전달받으면 ref를 props로 쓸 수 있다.
  • useImperativeHandle
    • useImperativeHandle을 사용하면 부모 컴포넌트에서 노출되는 값을 원하는 대로 바꿀 수 있다.
    • 자식 컴포넌트에서 전달 받은 ref에 useImperativeHandle을 사용하면 추가적인 동작을 정의할 수 있다.

useLayoutEffect

  • 리액트가 DOM을 업데이트 -> useLayoutEffect를 실행 -> 브라우저에 변경 사항을 반영 -> useEffect를 실행
  • 이 함수의 시그니처는 useEffect와 동일하나, 모든 DOM의 변경 후에 동기적으로 발생한다.
  • 중요한 사실은 모든 DOM의 변경 후에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생한다는 점이다.
    • 동기적으로 발생한다는 것은 리액트는 useLayoutEffect의 실행이 종료될 때까지 기다린 다음에 화면을 그린다는 것을 의미한다.
    • 즉, 리액트 컴포넌트는 useLayoutEffect가 완료될 때까지 기다리기 때문에 컴포넌트가 잠시 동안 일시 중지되는 것과 같은 일이 발생한다.
  • 언제 useLayoutEffect를 사용하는 것이 좋을까?
    • DOM은 계산됐지만 이것이 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 반드시 필요할 때만 사용하는 것이 좋다.

useDebugValue

  • 디버깅하고 싶은 정보를 이 훅에다 사용하면 리액트 개발자 도구에서 볼 수 있다.

훅의 규칙

  • 최상위에서만 훅을 호출해야 한다. 반복문이나 조건문, 중첩된 함수 내에서 훅을 실행할 수 없다. 이 규칙을 따라야만 컴포넌트가 렌더링될 때마다 항상 동일한 순서로 훅이 호출되는 것을 보장할 수 있다.
  • 훅을 호출할 수 있는 것은 리액트 함수 컴포넌트, 혹은 사용자 정의 훅의 두 가지 경우뿐이다. 일반 자바스크립트 함수에서는 훅을 사용할 수 없다.
  • 훅은 절대 조건문, 반복문 등에 의해 리액트에서 예측 불가능한 순서로 실행되게 해서는 안 된다.

사용자 정의 훅과 고차 컴포넌트 중 무엇을 써야 할까?

사용자 정의 훅

  • 서로 다른 컴포넌트 내부에서 같은 로직을 공유하고자 할 때 주로 사용되는 것이 바로 사용자 정의 훅이다.
  • 복잡하고 반복되는 로직은 사용자 정의 훅으로 간단하게 만들 수 있다.
  • 훅은 함수 컴포넌트 내부 또는 사용자 정의 훅 내부에서만 사용할 수 있다.

고차 컴포넌트

  • props의 변화가 없음에도 컴포넌트의 렌더링을 방지하기 위해 만들어진 리액트의 고차 컴포넌트가 바로 React.memo다
  • 사용자 정의 훅이 use로 시작하는 이름을 사용한 것처럼 리액트의 고차 컴포넌트도 마찬가지로 with로 시작하는 이름을 사용해야 한다.
    • eslint 제약 사항은 아니다.
  • 고차 컴포넌트가 컴포넌트를 또 다른 컴포넌트로 감싸는 구조로 돼 있다 보니, 여러 개의 고차 컴포넌트가 반복적으로 컴포넌트를 감쌀 경우 복잡성이 매우 커진다.

무엇을 써야 할까?

  • 사용자 정의 훅이 필요한 경우

    • 대부분의 고차 컴포넌트는 렌더링에 영향을 미치는 로직이 존재하므로 사용자 정의 훅에 비해 예측하기가 어렵다.
    • 단순히 컴포넌트 전반에 걸쳐 동일한 로직으로 값을 제공하거나 특정한 훅의 작동을 취하게 하고 싶다면 사용자 정의 훅을 사용하는 것이 좋다.
  • 고차 컴포넌트를 사용해야 하는 경우

    • 함수 컴포넌트의 반환값, 즉 렌더링의 결과물에도 영향을 미치는 공통 로직이라면 고차 컴포넌트를 사용하자.
    • 고차 컴포넌트가 많아질수록 복잡성이 기하급수적으로 증가하므로 신중하게 사용해야 한다.