useRef와 useState의 차이점과 쓰임

2026년 4월 18일

useRef가 내부적으로 어떻게 생겼는지, useState와 정확히 뭐가 다른지, 그리고 왜 특정 상황에서 ref가 아니면 풀 수 없는 문제가 있는지까지 다뤄 보고자 한다.

useRef의 실체 — 소스코드를 열어보자

많은 글에서 useRef를 설명할 때 "리렌더링을 일으키지 않는 값 저장소"라고 한다. 맞는 말이다. 근데 이 설명으로는 "왜?"에 답할 수가 없다.

React 소스코드(react-reconciler)에서 useRef의 구현을 보면 허무할 정도로 단순하다:

// 최초 렌더(mount) 시
function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = { current: initialValue };
  hook.memoizedState = ref;
  return ref;
}

// 리렌더(update) 시
function updateRef() {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

코드가 짧은데, 여기 나오는 mountWorkInProgressHook부터 이해해야 한다.

hook 연결 리스트 — React가 hook 데이터를 관리하는 방법

React에서 hook(useState, useRef, useEffect 등)을 쓰면, React 내부에서는 각 hook의 데이터를 연결 리스트(linked list) 형태로 관리한다. 각 Fiber 노드에 memoizedState라는 필드가 있고, 거기에 hook들이 줄줄이 연결돼있다:

mountWorkInProgressHook()이 하는 일은 단순하다. 새 hook 칸 하나 만들어서 이 연결 리스트 끝에 연결 시키는 거다.

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null,  // 이 hook이 저장할 데이터
    queue: null,          // update queue (useState만 씀)
    next: null,           // 다음 hook을 가리키는 포인터
  };

  // 연결 리스트 끝에 붙이기
  if (현재마지막훅 === null) {
    fiber.memoizedState = hook;  // 첫 번째 hook
  } else {
    현재마지막훅.next = hook;     // 기존 마지막 hook 뒤에 연결
  }

  return hook;
}

컴포넌트가 처음 렌더될 때(mount) mountWorkInProgressHook이 호출되고, 리렌더(update)될 때는 updateWorkInProgressHook이 호출된다. update 버전은 새로 만드는 게 아니라 이미 만들어진 연결 리스트를 순서대로 하나씩 꺼내오는 것이다:

function updateWorkInProgressHook() {
  const hook = 현재순회중인훅;
  현재순회중인훅 = hook.next;  // 다음 hook으로 포인터 이동
  return hook;
}

이게 바로 hook을 조건문이나 반복문 안에서 쓰면 안 되는 이유다. mount할 때 Hook1 → Hook2 → Hook3 순서로 만들었는데, update할 때 조건문 때문에 Hook2를 건너뛰면 순서가 꼬여서 Hook3의 데이터를 Hook2 자리에서 읽게 된다.

이제 useRef의 mount를 다시 보면:

function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();  // 1. 빈 hook 칸 하나 만들어서 리스트에 붙임
  const ref = { current: initialValue };   // 2. { current: 초기값 } 객체 생성
  hook.memoizedState = ref;                // 3. 그 객체를 hook 칸에 저장
  return ref;                              // 4. 객체 반환
}

update는:

function updateRef() {
  const hook = updateWorkInProgressHook();  // 1. 이미 만들어진 hook 칸 가져옴
  return hook.memoizedState;                // 2. 거기 저장된 객체 그대로 반환. 끝.
}

{ current: initialValue } — plain object 하나 만들어서 Fiber 노드의 hook 체인에 매달아두는 것뿐이다.

useState와 Fiber 레벨에서 비교하기

useRef가 왜 리렌더링을 안 일으키는지, useState 내부와 비교하면 바로 보인다.

// useState 내부 (simplified)
function mountState(initialState) {
  const hook = mountWorkInProgressHook();
  hook.memoizedState = initialState;
  hook.queue = { pending: null, dispatch: null /* ... */ };

  const dispatch = dispatchSetState.bind(null, currentFiber, hook.queue);
  hook.queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

차이가 보이는가? useState는 queue를 만든다. 이 queue가 전부다.

dispatchSetState — setState의 정체

우리가 const [count, setCount] = useState(0) 할 때 받는 setCount가 내부적으로 dispatchSetState다. 이 함수가 하는 일을 보면:

function dispatchSetState(fiber, queue, action) {
  // 1. update 객체 생성
  const update = {
    action: action,  // setState(5)면 5, setState(prev => prev+1)이면 그 함수
    next: null,
  };

  // 2. queue에 넣기
  queue.pending = update;

  // 3. React 스케줄러에게 알림: "이 fiber 다시 렌더해줘!"
  scheduleUpdateOnFiber(fiber);
}

3번이 핵심이다. scheduleUpdateOnFiber가 호출되면 React가 해당 컴포넌트를 리렌더 대상으로 등록한다.

setState(새값)을 호출하면 이런 일이 벌어진다:

  1. 새로운 update 객체를 만들어서 hook의 queue에 넣는다

  2. scheduleUpdateOnFiber() 호출 → React 스케줄러에게 "이 Fiber 다시 처리해"라고 알린다

  3. 스케줄러가 적절한 타이밍에 reconciliation을 시작하고, 해당 컴포넌트 함수를 다시 호출한다

useRef는? queue가 없다. dispatch 함수도 없다. ref.current = 새값은 JavaScript 객체의 프로퍼티를 바꾸는 것일 뿐이다. React 스케줄러한테 아무런 신호도 안 간다.

함수 컴포넌트에서 useRef가 필요한 근본적 이유

함수 컴포넌트는 함수다. 렌더링 = 함수 호출이다. 함수가 호출될 때마다 내부의 지역 변수는 새로 만들어진다:

function Counter() {
  let count = 0; // 렌더링마다 0으로 리셋

  const handleClick = () => {
    count += 1;
    console.log(count); // 1은 찍히지만...
  };

  return <button onClick={handleClick}>{count}</button>;
  // 화면에는 항상 0
}

let count = 0은 함수가 호출될 때마다 다시 선언된다. 클릭해서 count를 바꿔봤자, 다음 렌더에서 또 0이다.

"그러면 컴포넌트 밖에 변수를 선언하면 되지 않나?"

let count = 0; // 모듈 스코프

function Counter() {
  const handleClick = () => {
    count += 1;
  };
  return <button onClick={handleClick}>{count}</button>;
}

이건 더 위험하다. 같은 컴포넌트를 여러 곳에서 쓰면 하나의 변수를 공유한다. <Counter />를 세 군데 렌더링하면 세 인스턴스가 같은 count를 쓴다. A 인스턴스에서 클릭한 게 B, C 인스턴스에 영향을 미친다. 인스턴스별 격리가 안 되는 거다.

useRef는 이 문제를 깔끔하게 해결한다. 각 컴포넌트 인스턴스의 Fiber 노드에 값을 매달아두기 때문에 인스턴스 간 격리가 보장되면서도, 렌더링 사이에 값이 유지된다.

Render Phase vs Commit Phase — ref는 언제 붙는가

React의 렌더링 파이프라인은 두 단계로 나뉜다:

  • Render Phase: 컴포넌트 함수를 호출하고, Virtual DOM을 만들고, 이전 결과와 diff를 뜬다. 이 단계는 **순수(pure)**해야 한다. 부수효과가 없어야 하고, 중단될 수 있다.

  • Commit Phase: diff 결과를 실제 DOM에 반영한다. useEffect, useLayoutEffect가 실행되고, ref가 DOM 노드에 연결된다.

DOM ref(<div ref={myRef}>)의 경우, myRef.current에 실제 DOM 엘리먼트가 할당되는 시점은 Commit Phase다. React 내부의 commitAttachRef 함수가 이 시점에 ref.current = DOM노드를 해준다.

function Example() {
  const divRef = useRef(null);

  // ❌ Render Phase — ref.current가 아직 이전 값이거나 null
  console.log(divRef.current); // null일 수 있다

  useLayoutEffect(() => {
    // ✅ DOM 반영 직후 동기적으로 실행
    console.log(divRef.current); // <div> 엘리먼트
  });

  useEffect(() => {
    // ✅ DOM 반영 후 비동기적으로 실행
    console.log(divRef.current); // <div> 엘리먼트
  });

  return <div ref={divRef}>hello</div>;
}

순서를 정리하면:

컴포넌트 함수 실행 (Render Phase)DOM 업데이트 (Commit Phase 시작)
    ↓
ref.current = DOM 노드 (commitAttachRef)
    ↓
useLayoutEffect 실행 (동기)
    ↓
브라우저 paint
    ↓
useEffect 실행 (비동기)

이 순서를 모르면 "ref.current가 왜 null이지?" 하면서 삽질하게 된다.

렌더링 중에 ref.current를 건드리면 안 되는 이유

"렌더링 중에 ref.current를 읽거나 쓰지 마세요"라는 말을 들어본 적 있을 거다. 공식 문서에서도 강조하는 내용이다.

먼저 컴포넌트 함수의 본문이 실행되는 동안, 즉 return문 위에 있는 코드 영역이 "렌더링 중"이다:

function MyComponent() {
  // ===== 여기서부터 "렌더링 중" =====
  const ref = useRef(0);

  ref.current += 1;           // ❌ 렌더링 중에 ref를 "쓰는" 것
  console.log(ref.current);   // ❌ 렌더링 중에 ref를 "읽는" 것
  const x = ref.current * 2;  // ❌ 이것도 읽는 것
  // ===== 여기까지 "렌더링 중" =====

  return <div>{x}</div>;
}

반면, 이벤트 핸들러나 useEffect 안은 "렌더링 중"이 아니다:

function MyComponent() {
  const ref = useRef(0);

  const handleClick = () => {
    ref.current += 1; // ✅ 유저가 클릭할 때 실행됨. 렌더링 중 아님.
  };

  useEffect(() => {
    ref.current += 1; // ✅ 렌더링이 끝난 후 실행됨. 렌더링 중 아님.
  });

  return <button onClick={handleClick}>클릭</button>;
}

컴포넌트 함수 본문은 순수해야 하는 영역이다. 같은 props, 같은 state면 항상 같은 JSX가 나와야 한다. React는 컴포넌트 함수를 "언제든 다시 호출할 수 있다"고 가정하고, 이건 실제로 일어나는 일이다.

첫째, Strict Mode. React 18 이상의 개발 모드에서는 컴포넌트를 두 번 호출한다. 순수 함수라면 두 번 호출해도 결과가 같아야 하는데:

// 순수한 컴포넌트
function Pure({ name }) {
  return <div>안녕 {name}</div>;
}
// 1번 호출: <div>안녕 찬정</div>
// 2번 호출: <div>안녕 찬정</div>
// → 같다! ✅

// 순수하지 않은 컴포넌트
function Impure() {
  const count = useRef(0);
  count.current += 1;
  return <div>#{count.current}</div>;
}
// 1번 호출: <div>#1</div>
// 2번 호출: <div>#2</div>
// → 다르다! ❌

Strict Mode의 이중 호출은 "미래의 Concurrent 환경에서도 안전한 코드인지" 미리 테스트하는 것이다.

둘째, Concurrent Features. React 18부터 렌더링을 중간에 중단하고 다시 시작할 수 있다(Time Slicing).

React 18 이전:
  컴포넌트1 → 컴포넌트2... → 컴포넌트1000 (중간에 멈출 수 없음)
  이 동안 브라우저는 멈춰있음 (프레임 드랍, 버벅임)

React 18:
  컴포넌트1... → 컴포넌트200[잠깐!]
  "유저가 입력했네? 그거 먼저 처리하자"
  유저 입력 처리 완료
  → 컴포넌트201... → 컴포넌트1000 → 끝

단, 초기화 패턴은 예외

Render Phase에서 ref를 건드리는 것이 허용되는 딱 하나의 경우가 있다:

function Video() {
  const player = useRef(null);

  if (player.current === null) {
    player.current = new VideoPlayer();
  }

  return <div>...</div>;
}

이건 멱등(idempotent)하기 때문이다. 두 번 실행해도 결과가 같다. null 체크를 하니까 이미 생성되었으면 건너뛴다.

ref.current로 state 객체를 변이하면 안 되는 이유

useRef의 ref.current는 자유롭게 변이할 수 있다. 하지만 그 ref.current가 state 객체를 가리키고 있다면 얘기가 달라진다.

function Example() {
  const [user, setUser] = useState({ name: '찬정', age: 28 });
  const userRef = useRef(user);
}

이 시점에서 userRef.currentuser같은 객체를 가리킨다. JavaScript에서 객체는 참조로 전달되니까.

// ❌ 절대 하면 안 된다
userRef.current.name = '다른이름';

userRef.currentuser가 같은 객체니까 state 객체가 직접 변이된 거다. React는 setState를 통하지 않은 state 변경을 감지하지 못한다. 화면은 안 바뀌는데 데이터는 바뀌어있는 불일치 상태가 된다.

클로저 Stale Value 문제 — useRef가 유일한 해법인 순간

여기서부터가 useRef의 진짜 힘이 드러나는 부분이다. DOM 접근 같은 건 useRef 기능의 일부일 뿐이고, 클로저 문제를 풀 때 useRef가 아니면 해결이 안 되는 경우가 있다.

먼저, 클로저란?

클로저는 함수가 자기가 만들어졌을 때의 환경(변수들)을 기억하는 것이다:

function makeCounter() {
  let count = 0;  // 이 변수가 클로저에 의해 기억됨

  return function() {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
// makeCounter()는 이미 실행 끝났는데,
// 반환된 함수는 count 변수를 계속 기억하고 있다.

makeCounter()가 실행 끝났는데도 반환된 함수는 count에 접근할 수 있다. 함수가 자기가 태어난 환경의 변수를 붙잡고 있는 것이다. 이게 클로저다.

React에서 클로저가 문제가 되는 경우

React 함수 컴포넌트는 렌더링마다 함수가 새로 호출된다는 걸 기억하자:

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 항상 0
      setCount(count + 1); // 항상 0 + 1 = 1
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

useRef 해결 패턴

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 매 렌더마다 ref를 최신 값으로 동기화
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(countRef.current); // 항상 최신 값!
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

왜 countRef는 클로저에 갇히지 않는가?

"countRef도 1번째 렌더에서 클로저에 캡처되는 건 마찬가지 아닌가?" 맞다. 하지만 결정적 차이가 있다:

[1번째 렌더]
  count = 0원시값(숫자). 이 렌더의 지역 변수.
  countRef = { current: 0 }   ← 객체. Fiber에 매달린 그 하나의 객체.

  setInterval 콜백이 캡처한 것:
    - count → 숫자 0 (값 자체를 복사)
    - countRef → { current: 0 } 객체의 참조 (메모리 주소를 복사)

[2번째 렌더]
  count = 1                    ← 새로운 지역 변수. 1번째 렌더의 count와 다른 변수.
  countRef = { current: 0 }   ← 같은 객체! (useRef는 동일 객체를 반환)

  useEffect(() => { countRef.current = 1 })  ← 그 객체의 프로퍼티를 바꿈

  setInterval 콜백이 가진 것:
    - count → 여전히 0 (1번째 렌더에서 복사한 값. 바꿀 방법 없음)
    - countRef → 여전히 같은 객체를 가리킴.
      근데 그 객체의 .current가 1로 바뀜!
    - countRef.current를 읽으면 → 1 (최신 값!)

"클로저에 갇힌다"는 건, 사진을 찍어둔 count처럼 만들어진 시점의 값에 고정돼서 이후 변경을 반영하지 못하는 상태를 말하는 거다. countRef는 객체의 참조를 캡처했고, 그 객체 자체는 항상 같은 녀석이니까 .current를 통해 최신 값에 접근할 수 있는 거다.

useEffectEvent — 미래의 해법

사실 이 클로저 문제를 근본적으로 해결하기 위해 React 팀이 만들고 있는 API가 있다. useEffectEvent다:

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

  // useEffectEvent: 항상 최신 count를 읽되,
  // 이 함수 자체는 effect의 의존성으로 안 잡힘
  const onTick = useEffectEvent(() => {
    console.log(count); // 항상 최신 값!
  });

  useEffect(() => {
    const id = setInterval(onTick, 1000);
    return () => clearInterval(id);
  }, []);

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

useEffectEvent는 내부적으로 useRef + useLatest 패턴을 자동화한 것이다. 매 렌더마다 최신 클로저로 교체해주되, Effect의 의존성 배열에 영향을 주지 않는다. 하지만 2026년 4월 현재 아직 experimental API라서 프로덕션에서 쓰기엔 이르다. 정식 릴리즈되면 useRef로 수동 동기화하는 패턴을 대체할 수 있을 거다.

1. Callback Ref — DOM 노드의 생사를 감지하기

useRef로 만든 ref에는 한 가지 한계가 있다: .current가 바뀔 때 아무런 알림이 없다. 조건부 렌더링되는 요소의 크기를 측정하고 싶다면?

function DynamicMeasure() {
  const [show, setShow] = useState(false);
  const [height, setHeight] = useState(0);

  // ✅ DOM 노드가 attach/detach될 때 함수가 호출됨
  const measuredRef = useCallback((node) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <button onClick={() => setShow(!show)}>Toggle</button>
      {show && <div ref={measuredRef}>동적 콘텐츠</div>}
      <p>높이: {height}px</p>
    </>
  );
}

여기서 주목할 점: measuredRef에 useRef를 전혀 쓰지 않았다. ref prop에는 함수를 직접 넣을 수도 있다. 이걸 callback ref라고 한다.

사실 React의 ref prop은 세 가지 형태를 받을 수 있다:

// 1. useRef 객체
const myRef = useRef(null);
<div ref={myRef} />

// 2. callback 함수 — useRef 없이도 가능
<div ref={(node) => { /* node로 뭔가 함 */ }} />

// 3. string ref (레거시 — 쓰지 마)
<div ref="myDiv" />  // ❌ deprecated

Callback ref는 React가 이 함수를:

  • DOM 노드가 마운트될 때 → 해당 DOM 노드를 인자로 호출

  • DOM 노드가 언마운트될 때 → null을 인자로 호출

useRef와의 결정적 차이는 DOM이 붙고 떨어질 때 알림을 받을 수 있다는 거다. useRef는 .current가 바뀌어도 아무 이벤트가 안 나온다. 그래서 조건부 렌더링되는 요소를 추적하거나, Intersection Observer를 붙일 때 callback ref가 더 적합하다.

2. useImperativeHandle — 부모에게 리모컨 넘기기

const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; },
    shake: () => {
      inputRef.current.style.animation = 'none';
      inputRef.current.offsetHeight; // reflow 강제
      inputRef.current.style.animation = 'shake 0.3s';
    }
    // 부모는 DOM 노드 전체가 아니라 이 세 메서드만 접근 가능
  }));

  return <input ref={inputRef} {...props} />;
});

부모가 자식의 DOM 노드 전체를 받으면 뭘 할지 모른다. style을 직접 바꾸거나, innerHTML을 조작하거나. useImperativeHandle은 허용된 동작만 노출하는 리모컨을 만드는 거다.

3. useEffect cleanup에서의 ref 캡처

이건 미묘하지만 중요한 버그 패턴이다. 이해하려면 먼저 cleanup 함수의 실행 타이밍을 정확히 알아야 한다.

cleanup은 언제 실행되는가?

useEffect(() => {
  console.log('effect 실행: roomId =', roomId);
  const conn = createConnection(roomId);
  conn.connect();

  return () => {
    console.log('cleanup 실행: roomId =', roomId);
    conn.disconnect();
  };
}, [roomId]);

"이전 effect의 cleanup → 새 effect 실행" 순서다. 정리하고 나서 새로 시작하는 거다. 방 청소하고 나서 새 방으로 이사하는 것처럼. cleanup이 실행된다고 끝나는 게 아니라, 정리 후에 새 effect가 바로 이어서 실행된다.

그런데 ref.current를 cleanup에서 쓰면 문제가 생길 수 있다

function ChatRoom({ roomId }) {
  const connectionRef = useRef(null);

  useEffect(() => {
    connectionRef.current = createConnection(roomId);
    connectionRef.current.connect();

    return () => {
      // ❌ 이 시점에 connectionRef.current가 다른 값일 수 있다
      connectionRef.current.disconnect();
    };
  }, [roomId]);
}

connectionRef.current공유된 mutable 값이다. cleanup 함수가 실행되는 시점에 다른 effect나 이벤트 핸들러가 connectionRef.current를 이미 바꿨을 수 있다. 그러면 room1 연결을 끊어야 하는데 room2 연결을 끊어버리는 사태가 벌어질 수 있다.

로컬 변수에 캡처하면 이 문제가 원천 차단된다:

useEffect(() => {
  const connection = createConnection(roomId); // ✅ 로컬 변수에 캡처
  connectionRef.current = connection;
  connection.connect();

  return () => {
    connection.disconnect(); // ✅ 이 클로저가 기억하는 건 이 effect에서 만든 연결
    // connectionRef.current가 뭐가 됐든 상관없음
  };
}, [roomId]);

connection은 이 effect 함수 안의 지역 변수다. 클로저가 이 값을 캡처하고 있으니까, cleanup에서 항상 "이 effect에서 만든 그 연결"을 끊게 된다.

React 19에서 달라진 것들

ref가 그냥 props다

React 19부터 forwardRef 없이 ref를 props로 직접 받을 수 있게 됐다:

// React 19+
function FancyInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

// forwardRef 없이도:
<FancyInput ref={myRef} />

forwardRef가 deprecated된 건 아니지만, 새 코드에서는 props로 받는 패턴이 권장된다. ref가 결국 "특별한 뭔가"가 아니라 그냥 값을 전달하는 것에 불과하다는 React 팀의 철학이 반영된 거다.

ref callback의 cleanup 반환

React 19에서 callback ref가 cleanup 함수를 반환할 수 있게 됐다. 이게 뭐가 대단한지, 기존 패턴과 비교하면 명확하다.

예를 들어 DOM 요소에 IntersectionObserver를 붙이고 싶다고 하자.

기존 패턴 1: useRef + useEffect (React 18 이하)

function LazyImage({ src }) {
  const imgRef = useRef(null);

  useEffect(() => {
    const node = imgRef.current;
    if (!node) return;

    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        node.src = src;
        observer.disconnect();
      }
    });

    observer.observe(node);

    return () => {
      observer.disconnect();
    };
  }, [src]);

  return <img ref={imgRef} />;
}

useRef 1개에 useEffect 1개. DOM 요소가 조건부 렌더링되면 더 복잡해진다. imgRef.current가 null인 경우를 체크해야 하고, 요소가 사라졌다가 다시 나타나면 effect를 다시 실행해야 하는데 src가 안 바뀌면 effect가 다시 안 돈다.

React 19 패턴: callback ref에서 cleanup 반환

function LazyImage({ src }) {
  return (
    <img ref={(node) => {
      // node는 항상 실제 DOM 엘리먼트 (cleanup이 있으면 null 호출 안 함)
      const observer = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          node.src = src;
          observer.disconnect();
        }
      });

      observer.observe(node);

      // cleanup 반환 — 언마운트되거나 ref가 바뀔 때 자동 호출
      return () => observer.disconnect();
    }} />
  );
}

useRef 0개, useEffect 0개, useCallback 0개. ref prop에 함수 하나 넣고 cleanup을 return으로 넘기면 끝이다. useEffect의 패턴과 동일한 구조가 됐다. React 19에서는 cleanup이 반환되면 null로 콜백을 호출하지 않아서 null 체크도 필요 없다.

요약

useRef는 단순한 API다. { current: value } 객체 하나를 Fiber에 매달아두는 것뿐이다. 하지만 그 단순함 때문에 오히려 "언제, 어디서, 왜 써야 하는지"를 정확히 알아야 한다.

  • 왜 리렌더링이 안 되는가 → queue가 없다. dispatchSetState 같은 스케줄러 알림 메커니즘이 아예 존재하지 않는다.

  • 왜 렌더링 중에 건드리면 안 되는가 → 컴포넌트 함수 본문은 순수해야 하고, React는 이 함수를 언제든 다시 호출하거나 중단할 수 있다. ref.current는 plain object의 프로퍼티라서 React가 롤백할 방법이 없다.

  • 왜 state 객체를 ref로 변이하면 안 되는가 → 같은 참조를 가리키고 있으면 React 몰래 state를 바꾸는 꼴이다.

  • 왜 클로저 문제에서 ref가 해법이 되는가 → 클로저가 캡처하는 건 ref 객체의 참조(메모리 주소)이고, 그 객체는 항상 동일하므로 .current를 통해 최신 값에 접근할 수 있다. 원시값은 값 자체가 복사되어 갇히지만, 객체 참조는 갇혀도 내용물이 바뀌면 최신 값을 읽을 수 있다.

  • DOM ref는 언제 붙는가 → Commit Phase의 commitAttachRef에서. Render Phase에서는 보장 안 된다.

출처

React 공식 문서 (react.dev)

React 소스코드 (GitHub)

React 19 관련

소스코드 분석 참고