🚀 94sssh
Published on

2025.03.22

[리액트 딥다이브] - 03. 리액트 훅 깊게 살펴보기

3.1. 리액트의 모든 훅 파헤치기

3.1.1. useState

useState는 함수 컴포넌트 내부에서 상태를 정의하고 관리할 수 있게 해준다.

import { useState } from 'react'

const [state, setState] = useState(initialState)

useState를 사용하지 않고 자체적으로 변수를 상태값으로 사용하는 경우, state를 업데이트해도 렌더링이 일어나지 않는다.

리액트의 렌더링은 함수 컴포넌트의 return 값을 비교해 실행되기 때문. 매번 렌더링시 새롭게 실행되는 함수에서 state가 늘 초기화되기 때문.

const MyReact = (function () {
  const global = {}
  let index = 0

  function useState(initialState) {
    if (!global.states) {
      // 애플리케이션 전체의 states 배열 초기화
      global.states = []
    }
  }

  // states에 현재 상태값이 있는지 확인하고, 없다면 초기값으로 설정
  const currentState = global.states[index] || initialState
  // states 값을 위에서 조회한 현재 값으로 업데이트
  global.states[index] = currentState

  // 즉시 실행 함수로 setter 생성
  const setState = (function () {
    // 현재 index를 클로저로 가둬놔 이후에도 동일한 index에 접근할 수 있게 처리
    let currentIndex = index
    return function (value) {
      global.states[currentIndex] = value
      // 컴포넌트 렌더링 코드 생략
    }
  })()

  index = index + 1

  return [currentState, setState]
})()

게으른 초기화
useState의 기본값으로 변수 대신 특정한 값을 넘기는 함수를 인수로 넘기는 것
useState의 초깃값이 복잡하거나 무거운 연산을 포함하고 있을 때 사용, state가 처음 만들어질 때만 사용되고, 이후 리렌더링 시 함수의 실행이 무시되기 때문.
localStorage, sessionStorage, map, filter, find 같은 배열에 대한 접근 등

3.1.2. useEffect

useEffect는 생명주기 메서드를 대체하기 위해 만들어진 훅이 아니다. 애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘이다.
state와 props의 변화로 일어나는 렌더링에서 의존성에 있는 값이 이전과 다른 게 있다면 부수 효과를 실행하는 함수.

클린업 함수의 목적
클린업 함수는 새로운 값을 기반으로 렌더링 뒤에 실행되지만, 변경된 값이 아니라 함수 정의 당시 선언된 값을 보고 실행된다.

함수 컴포넌트의 useEffect는 콜백이 실행될 때마다 이전의 클린업 함수가 존재한다면 클린업 함수를 실행한 뒤에 콜백을 실행한다. 그렇기 때문에 이벤트 핸들러가 무한히 추가되는 것을 방지하기 위해 이벤트 추가 전에 이전에 등록한 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것.

언마운트의 개념과는 조금 다른 개념으로 클린업 함수는 말 그대로 이전 상태를 청소해 주는 개념

의존성 배열
빈 배열을 넘긴다면 비교할 의존성이 없어 최초 렌더링 후 더 이상 실행되지 않고, 아무런 값도 넘기지 않으면 매 렌더링마다 실행된다.

매 렌더링마다 실행되는데도 useEffect를 쓰는 이유는 다음과 같다.

function Component() {
  console.log('렌더링')
}

function Component() {
  useEffect(() => {
    console.log('렌더링')
  })
}
  1. useEffect는 클라이언트 사이드에서 실행되는 것을 보장. window 객체에 접근하는 코드 사용 가능
  2. 직접 실행은 렌더링 도중 실행되고, useEffect는 렌더링 완료 후 실행된다. 렌더링을 방해하지 않아 성능에 유리

useEffect의 구현

const MyReact = (function () {
  const global = {}
  let index = 0

  function useEffect(callback, dependencies) {
    const hooks = global.hooks

    let previousDependencies = hooks[index]

    let isDependenciesChanged = previousDependencies
      ? dependencies.some((value, idx) => !Object.is(value, previousDependencies[idx]))
      : true

    if (isDependenciesChanged) {
      callback()
    }

    hooks[index] = dependencies

    index++
  }

  return { useEffect }
})()

useEffect를 사용할 때 주의할 점

  • eslint-disable-line react-hooks/exhaustive-deps
    의존성 배열을 넘기지 않은 채 콜백 함수 내부에서 특정 값을 사용하는 건, 컴포넌트의 state, props와 같은 어떤 값의 변경과 useEffect의 부수 효과가 별개로 작동하는 것.
    빈 배열을 넘기기 전에 정말 useEffect의 부수 효과가 컴포넌트의 상태와 별개로 작동해야하는지, 호출 위치가 최선인지 검토해 봐야 함.
    특정 값을 사용하되 변경 시점을 피할 목적이라면 메모이제이션을 활용

  • useEffect의 첫 번째 인수에 함수명을 부여하라
    코드가 복잡하고 많아지면 무슨 일을 하는 useEffect 코드인지 파악이 어려워짐. 익명 함수가 아닌 기명 함수를 써서 해결 가능

    useEffect(
      function logActiveUser() {
        logging(user.id)
      },
      [user.id]
    )
    
  • 거대한 useEffect를 만들지 마라
    부수 효과가 커질수록 성능에 악영향 적은 의존성 배열을 사용하는 여러 개의 useEffect로 분리

  • 불필요한 외부 함수를 만들지 마라
    useEffect 내에서 사용할 부수 효과라면 내부에서 만들어서 정의해서 사용하기

useEffect의 콜백 인수로 비동기 함수를 넣을 수 없는 이유
비동기 함수 호출 시 발생할 수 있는 useEffect의 경쟁 상태 때문
useEffect의 인수로 비동기 함수를 지정할 수 없는 것이지, 비동기 함수 실행 자체가 문제가 되진 않음.

useEffect(() => {
  let shouldIgnore = false

  async function fetchData() {
    const response = await fetch('http://some.data.com')
    const result = await response.json()
    if (!shouldIgnore) {
      setData(result)
    }
  }

  fetchData()

  return () => {
    shouldIgnore = true
  }
}, [])

비동기 함수가 내부에 존재하면 내부에서 비동기 함수의 생성과 실행을 반복하므로 클린업 함수에서 이전 비동기 함수의 처리를 해주는 것이 좋음.

3.1.3. useMemo

비용이 큰 연산에 대한 결과를 저장해두고, 저장된 값을 반환하는 훅
useMemo로 컴포넌트도 감쌀 수 있지만, React.memo를 쓰는 것이 현명.

3.1.4. useCallback

인수로 넘겨받은 콜백 자체를 기억
useCallback을 추가하면 해당 의존성이 변경됐을 때만 함수가 재생성되어 불필요한 리소스 또는 리렌더링을 방지할 수 있음.

3.1.5. useRef

useRef는 원하는 시점의 값을 렌더링에 영향을 미치지 않고 보관하고 싶을 때 사용

useRefuseState의 차이점 두 가지

  • useRef는 반환값인 객체 내부에 있는 current로 값에 접근 또는 변경할 수 있음
  • useRef는 값이 변해도 렌더링을 발생시키지 않음

useRef를 사용하지 않고 함수 외부에서 값을 선언하고 관리하면 다음과 같은 단점이 있음

  • 컴포넌트가 실행되어 렌더링되지 않아도 메모리에 불필요한 값을 갖게 함
  • 각 컴포넌트 인스턴스가 가리키는 값이 모두 동일함

3.1.6. useContext

Context란?

명시적인 props 전달 없이 선언한 하위 컴포넌트 모두에서 원하는 값을 사용할 수 있게 해줌.

const Context = createContext<{ hello: string } | undefined>(undefined)

function ParentComponent() {
  return (
    <Context.Provider value={{ hello:'react' }}>
      <Context.Provider value={{ hello:'javascript' }}>
        <ChildComponent />
      </Context>
    </Context>
  )
};

function ChildComponent() {
  const value = useContext(Context);

  return value ? value.hello : ''; // javacript 반환
}

useContext는 상위 컴포넌트에서 선언된 Provider가 제공한 값을 가져올 수 있으며, 여러 개의 Provider가 있다면 가장 가까운 값을 사용

useContext를 사용할 때 주의할 점

Provider에 의존성을 가지게 되므로 컴포넌트 재활용이 어려워짐.
리소스 낭비를 방지하기 위해선 컨텍스트가 미치는 범위를 최대한 좁게 만든다.

useContext는 상태 관리 API가 아님, 콘텍스트는 상태를 주입해 주는 API로, 상태 관리 라이브러리가 되기 위해서는 다음 두 가지를 만족해야 함.

  1. 어떠한 상태를 기반으로 다른 상태를 만들어낼 수 있어야 함
  2. 필요에 따라 이러한 상태 변화를 최적화할 수 있어야 함

콘텍스트는 단순히 props 값을 전달할 뿐, 렌더링이 최적화되지 않는다.

3.1.7. useReducer

useReduceruseState의 심화 버전으로 볼 수 있다. useState와 비슷하지만, 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있음.

  • 반환값은 useState와 동일하게 길이가 2인 배열([state, dispatcher])
    • state: 현재 useReducer가 가지고 있는 값
    • dispatcher: state를 업데이트하는 함수
  • useState와 달리 2~3개의 인수 필요(reducer, initialState, init)
    • reducer: useReducer의 기본 action을 정의하는 함수
    • initialState: 초깃값
    • init: 초깃값을 지연해서 생성시키고 싶을 때 사용하는 함수, 필수값은 아니며, 인수로 넘기는 함수가 있으면 useState와 동일하게 게으른 초기화 발생

useReducer는 state를 dispatcher로만 수정할 수 있게 만들어 state 값을 변경하는 시나리오를 제한적으로 두게 만듬
여러 개의 state를 관리하는 것보다, 묶어서 useReducer로 관리하는 편이 효율적일 수 있음.

3.1.8. useImperativeHandle

forwardRef 살펴보기

forwardRef는 ref를 전달하는 데 일관성을 제공하기 위해 사용

useImperativeHandle이란?

부모에게서 넘겨받은 ref를 원하는 대로 수정할 수 있는 훅. ref의 동작을 추가로 정의할 수 있음.

3.1.9. useLayoutEffect

useEffect와 동일한 모습으로 작동하지만 모든 DOM의 변경, 즉 렌더링 이후 동기적으로 발생하는 훅

항상 useEffect보다 먼저 실행되며, 리액트 컴포넌트는 useLayoutEffect가 완료될 때까지 기다리므로 웹 애플리케이션의 성능에 문제가 발생할 수 있다.

DOM은 계산됐지만 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 반드시 필요할 때만 사용할 것

3.1.10. useDebugValue

개발 과정에 사용되는 훅으로, 디버깅하고 싶은 정보를 사용하여 개발자 도구에서 볼 수 있다.

3.1.11. 훅의 규칙

리액트의 훅은 rules-of-hooks라고 하는 규칙이 있고, ESLint 규칙인 react-hooks/rules-of-hooks도 존재.

  1. 최상위에서만 훅을 호출해야 함. 반복문, 조건문, 중첩된 함수 내에서 훅을 실행할 수 없음.
  2. 훅은 리액트 함수 컴포넌트, 사용자 정의 훅 두 가지 경우에서만 호출할 수 있음.

훅의 실행 순서를 보장하기 위해 항상 컴포넌트 최상단에 선언되어야 하며, 조건문과 반복문 등으로 예측 불가능한 순서로 실행되게 해서는 안 됨. 조건문이 필요하다면 훅 내부에서 수행.

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

사용자 정의 훅과 고차 컴포넌트를 통해 재사용할 수 있는 로직을 관리할 수 있음

3.2.1. 사용자 정의 훅

이름이 반드시 use로 시작해야 함

3.2.2. 고차 컴포넌트

리액트에서 가장 유명한 고차 컴포넌트는 React.memo

React.memo란?

props의 변화가 없을 때, 리렌더링을 방지하기 위해 만들어진 고차 컴포넌트, props를 비교해 이전과 같다면 렌더링 자체를 생략한다. 부모 컴포넌트에서 state가 변경되어도 리렌더링되지 않는다.

고차 함수를 활용한 고차 컴포넌트 만들어보기

고차 컴포넌트를 사용할 때 주의점은 부수 효과를 최소화해야 하는 것으로, 고차 컴포넌트는 반드시 컴포넌트를 인수로 받게 되는데, 컴포넌트의 props를 임의로 수정, 추가, 삭제하면 안 된다.

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

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

단순히 useEffect, useState 같이 리액트 제공 훅으로 공통 로직을 격리할 수 있는 경우

고차 컴포넌트를 사용해야 하는 경우

렌더링의 결과물에 영향을 미치는 공통 로직인 경우