3. Mastering React Hooks

리액트 훅은 “API 사용법”이 아니라, 클로저·참조 비교·함수 재실행이라는 자바스크립트 원리 위에서 렌더링과 부수효과를 의도적으로 제어하기 위한 설계 도구


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

1.1 useState: 상태 관리의 비밀과 클로저

useState는 함수 컴포넌트에서 상태를 관리할 수 있게 해주는 가장 기본적인 훅입니다. 하지만 단순히 “값을 저장하는 변수”로 이해하면 리액트의 렌더링 모델을 오해하게 됩니다.

🧐 Q. 왜 일반 변수는 안 될까?

함수 컴포넌트는 렌더링이 발생할 때마다 함수 자체가 다시 실행됩니다. 즉, 함수 내부의 let, const 변수는 매번 초기화됩니다.

function Component() {
  let state = 'hello'
  // 렌더링이 다시 되면 state는 항상 'hello'
}

useState의 상태는 컴포넌트 함수 안에 저장되지 않고, 리액트 내부에 따로 보관됩니다.

컴포넌트 함수
 ├─ count: 현재 렌더에서 보여줄 값
 └─ setCount: 리액트 내부 상태를 가리키는 함수

useState가 상태를 기억하는 방법

리액트는 클로저(Closure) 를 활용해 상태를 컴포넌트 외부(리액트 내부)에 저장합니다. 그리고 setState는 이 외부 저장소를 참조하는 함수로 만들어집니다. 덕분에 컴포넌트가 다시 실행되어도, 이전 상태를 “기억”할 수 있습니다. useState값이 아니라 함수를 넘기면, 그 함수는 최초 렌더링 시 단 한 번만 실행됩니다.

const [count, setCount] = useState(() => {
  return Number(localStorage.getItem('count'))
})


1.2 useEffect: 생명주기가 아닌 부수 효과

useEffect“렌더링 결과를 기반으로 부수 효과를 만드는 도구”입니다.

핵심 개념: 렌더링 이후 실행 (즉, 렌더링 과정에 직접 개입하지 않습니다.)

  • 렌더링 → 화면 반영
  • 그 다음 useEffect 실행

1.2.1 의존성 배열의 진짜 의미

의존성 배열은 “이 값들이 바뀌면, 이 effect는 더 이상 유효하지 않다”는 선언입니다.

useEffect(() => {
  // side effect
}, [a, b])

리액트는 의존성 배열을 Object.is 기반의 얕은 비교로 검사합니다.

  • a 또는 b 중 하나라도 이전과 다르면 effect 재실행
  • 객체, 배열은 내용이 아니라 참조로 비교
    • 새 객체 → 값이 같아도 “변경됨”으로 판단

1.2.2 클린업(Cleanup) 함수의 정확한 역할

다음 effect가 실행되기 직전에, 이전 effect를 정리하는 함수

(1) 렌더링 A → effect A 실행

(2) 상태 변경 → 렌더링 B

(3) effect B 실행 전에 effect A의 cleanup 실행

(4) effect B 실행


1.3 useMemo와 useCallback: 메모이제이션의 본질

useMemo

  • 계산 결과를 기억
  • 의존성이 바뀌지 않으면 이전 결과 재사용
const value = useMemo(() => expensiveCalc(a, b), [a, b])

useCallback

  • 함수 자체를 기억
  • 자식 컴포넌트로 함수 전달 시 유용
const onClick = useCallback(() => {
  doSomething()
}, [])


1.4 useRef: 렌더링과 무관한 값

useRef는 값이 바뀌어도 렌더링을 발생시키지 않습니다.

  • DOM 직접 접근
  • 이전 값 저장
  • 타이머 ID, AbortController 등
const timerRef = useRef<number | null>(null)
// 상태(state)가 아니라 “그냥 보관용 상자”라고 생각하면 이해가 쉽습니다.


1.5 useContext: Prop Drilling 해결의 대가

(1) Prop Drilling 문제_ C에서 값을 쓰기 위해 A, B는 **필요도 없는 props를 전달만 함

App
 └─ A
    └─ B
       └─ C (값 필요)

(2) useContext로 해결

// 대안 ① Context + React.memo
// 👉 “필요 없는 리렌더링이 더 내려가지 않게 막자”
const Profile = React.memo(function Profile({ user }) {
  return <Avatar name={user.name} />
})

function Parent() {
  const user = useContext(UserContext)

  return <Profile user={user} />
}

// Parent는 Context 변경 때문에 다시 실행됨 ❗
// 하지만 user 참조가 같다면
// Profile은 렌더링 안 됨
  
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

// 대안 ② Context를 역할별로 분리
// 👉 “한 값 바뀌었다고 전부 흔들지 말자”
<UserContext value={user}>
<ThemeContext value={theme}>


1.6 useReducer: 상태 로직 분리

useReducer상태를 어떻게 바꿀지에 대한 로직을 컴포넌트 밖으로 분리하는 훅이다.

❌ useState로 상태를 직접 관리할 때

const [state, setState] = useState({
  loading: false,
  data: null,
  error: null,
})

setState({ ...state, loading: true })
setState({ loading: false, data, error: null })
setState({ loading: false, data: null, error })

✅ useReducer로 상태 로직을 분리했을 때

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true }
    case 'FETCH_SUCCESS':
      return { loading: false, data: action.data, error: null }
    case 'FETCH_ERROR':
      return { loading: false, data: null, error: action.error }
  }
}

const [state, dispatch] = useReducer(reducer, initialState)

dispatch({ type: 'FETCH_START' })
dispatch({ type: 'FETCH_SUCCESS', data })
dispatch({ type: 'FETCH_ERROR', error })


1.7 기타 고급 훅

  • useImperativeHandle → ref로 노출할 인터페이스 제어
  • useLayoutEffect → DOM 변경 직후, 페인팅 이전 실행
  • useDebugValue → Custom Hook 디버깅용



2. 사용자 정의 훅과 고차 컴포넌트

2.1 사용자 정의 훅 (Custom Hook)

“상태 + effect 로직의 재사용”

  • 반드시 use로 시작
  • 렌더링 결과에는 직접 영향 없음
  • 로직 공유에 최적

✔️ 가장 권장되는 방식


2.2 고차 컴포넌트 (HOC)

“컴포넌트를 감싸서 새로운 컴포넌트 생성”

  • 대표 예: React.memo
  • withSomething 네이밍 관례
  • 렌더링 구조에 직접 개입



reference: 모던리액트 Deep Dive 3장. 리액트 훅 깊게 살펴보기