
useRef의 핵심 기능 중 하나는 렌더링에 필요하지 않은 값을 참조할 수 있게 해주는 React Hook이다. useRef는 { current: initialValue } 형태의 객체를 반환하며, 이 객체는 컴포넌트의 다음 렌더링 시에도 동일하게 유지된다. 그러나 useRef는 상당히 직관적이지 못해 헷갈리는 부분이 있다. 이에 대해 예시 코드와 함께 알아보자.
useState와 달리 useRef의 current 속성을 변경해도 컴포넌트는 다시 렌더링되지 않는다. 이 때문에 useRef는 화면에 표시할 데이터가 아닌, 컴포넌트의 시각적 출력에 영향을 주지 않는 정보를 저장하는 데 적합하다. 예를 들어, 타이머의 ID나 이전 값을 저장할 때 유용하다.
다음 코드는 버튼을 클릭할 때마다 ref 값을 증가시키고 alert로 보여준다. ref.current 값은 제대로 증가하지만, 만약 JSX 안에 {ref.current}를 표시하더라도 그 값은 업데이트되지 않는다.
import { useRef } from 'react';
export default function Counter() {
// ref를 선언하고 초기값 0으로 설정
let ref = useRef(0);
function handleClick() {
// 이벤트 핸들러 안에서 ref.current 값을 변경
ref.current = ref.current + 1;
alert('You clicked ' + ref.current + ' times!'); // alert는 업데이트된 값을 보여준다.
}
return (
<button onClick={handleClick}>
Click me!
</button>
);
}
혼동 지점: {ref.current}를 <h1>{ref.current}</h1>처럼 JSX에 직접 넣으면 숫자가 클릭해도 업데이트되지 않는다. 왜냐하면 ref.current를 설정하는 것은 리렌더링을 유발하지 않기 때문이다. 화면에 표시해야 할 정보라면 useState를 사용해야 한다.
React는 컴포넌트 본문이 순수 함수처럼 동작하기를 기대한다. 즉, 같은 입력(props, state 등)이 주어지면 항상 같은 JSX를 반환해야 하며, 호출 순서에 영향을 받지 않아야 한다. 하지만 렌더링 중에 ref.current를 읽거나 쓰는 것은 이러한 기대치를 깨뜨려 컴포넌트의 동작을 예측 불가능하게 만든다.
ref.current는 이벤트 핸들러나 이펙트 안에서 읽거나 써야 한다. 초기화 시점(컴포넌트 함수 맨 처음 또는 if (ref.current === null)과 같은 조건부 로직)은 예외이다. 만약 렌더링 중에 꼭 무언가를 읽거나 써야 한다면, 대신 state를 사용해야 한다.
잘못된 접근 (렌더링 중 읽기/쓰기):
function MyComponent() {
const myRef = useRef(null);
const myOtherRef = useRef(0);
// 🚩 잘못됨: 렌더링 중에 ref에 쓴다.
myRef.current = 123;
// 🚩 잘못됨: 렌더링 중에 ref를 읽는다.
return <h1>{myOtherRef.current}</h1>;
}
올바른 접근 (이벤트 핸들러/이펙트 안에서 읽기/쓰기):
import { useRef, useEffect } from 'react';
function MyComponent() {
const myRef = useRef(null);
const myOtherRef = useRef(0);
useEffect(() => {
// ✅ 올바름: 이펙트 안에서 ref를 읽거나 쓴다.
myRef.current = 123;
});
function handleClick() {
// ✅ 올바름: 이벤트 핸들러 안에서 ref를 읽거나 쓴다.
const value = myOtherRef.current;
console.log(value);
}
return (
<button onClick={handleClick}>
Do Something
</button>
);
}
혼동 지점: 컴포넌트 함수가 실행되는 시점이 "렌더링 중"이다. 이 시점에는 부작용(side effects)을 일으키거나 외부 상태를 변경해서는 안 된다. ref.current를 변경하는 것은 부작용에 해당하며, 이를 렌더링 중에 수행하면 예측 불가능한 결과를 초래할 수 있다.
내장 DOM 요소 (<input>, <div> 등)에는 ref 속성을 직접 전달하여 해당 DOM 노드에 접근할 수 있다.
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
// 버튼 클릭 시 input 요소에 포커스
inputRef.current.focus();
}
return (
<>
{/* <input> 요소에 ref 객체를 전달한다. */}
<input ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
혼동 지점: 하지만 직접 만든 사용자 정의 컴포넌트에 ref prop을 전달하면 기본적으로 내부의 DOM 노드에 ref가 연결되지 않는다. TypeError: Cannot read properties of null과 같은 오류가 발생할 수 있다.
해결 방법: 사용자 정의 컴포넌트에서 ref prop을 받아 내부의 내장 DOM 요소에 다시 ref 속성으로 전달해주어야 한다. 이를 위해서는 일반적으로 forwardRef라는 다른 React 기능이 필요하지만, 소스에서는 단순히 prop으로 ref를 받아서 전달하는 예시를 보여주고 있다.
원래 MyInput 컴포넌트:
export default function MyInput({ value, onChange }) {
return (
<input
value={value}
onChange={onChange}
/>
);
}
이 컴포넌트에 부모에서 ref를 넘겨주려고 하면 오류가 발생한다.
// 부모 컴포넌트 (MyInput 컴포넌트를 사용)
import { useRef } from 'react';
import MyInput from './MyInput'; // 위에서 만든 MyInput
export default function Form() {
const inputRef = useRef(null);
// ...
// <MyInput ref={inputRef} /> // 이렇게 하면 에러 발생 가능
// ...
}
오류를 해결하기 위해 MyInput 컴포넌트를 수정한다:
// 수정된 MyInput 컴포넌트: ref prop을 받아서 내부 <input>에 연결
function MyInput({ value, onChange, ref }) { // ref prop을 받는다.
return (
<input
value={value}
onChange={onChange}
ref={ref} // 받은 ref prop을 내부 <input>에 전달한다.
/>
);
} ;
export default MyInput;
이렇게 수정하면 부모 컴포넌트에서 ref를 전달했을 때 해당 ref.current가 MyInput 내부의 <input> DOM 노드를 가리키게 된다.
혼동 지점: ref는 일반적인 prop처럼 보이지만, React에 의해 특별하게 처리된다. 사용자 정의 컴포넌트에서 ref를 지원하려면 컴포넌트 자체가 이를 인지하고 내부 DOM 요소에 연결하는 로직이 필요하다는 점이 헷갈릴 수 있다. (일반적으로 forwardRef와 함께 사용되지만, 소스에서는 prop으로 받는 방식을 보여준다.)
요약하자면, useRef는 렌더링과 무관한 데이터를 컴포넌트 인스턴스 레벨에서 유지하기 위한 강력한 도구이지만, 리렌더링을 유발하지 않는다는 점, 렌더링 주기 중 접근 규칙, 그리고 사용자 정의 컴포넌트에서 ref를 다루는 특별한 방식 때문에 사용 시 주의가 필요하며, 이러한 부분들이 가장 헷갈리는 핵심이라고 할 수 있다.