forwardRef의 은퇴와 useImperativeHandle: 이제 ref는 그냥 Prop입니다

크리스가 회원가입 폼을 만들고 있다.
"이메일 중복 확인" 버튼을 눌렀을 때 이미 가입된 이메일이라면, 이메일 입력창(CustomInput)에 빨간 불이 들어오고 자동으로 포커스(Focus)가 잡히게 하고 싶다.
부모 컴포넌트에서 자식의 input에 접근해야 하니, 크리스는 배운 대로 ref를 넘겨주기로 했다.
// Parent.tsx
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.focus(); // 💥 에러 발생: current가 undefined?
};
return <CustomInput ref={inputRef} />;
}
// CustomInput.tsx
// ❌ 기존 방식: ref는 props로 전달되지 않는다.
function CustomInput(props) {
return <input ref={props.ref} />; // props.ref는 undefined다.
}콘솔창에는 "Function components cannot be given refs..."라는 붉은 경고가 뜬다.
리액트에서 ref는 key와 함께 특별 취급을 받는 예약어라서, 일반적인 props로 전달되지 않기 때문이다.
이 문제를 해결하기 위해 우리는 그동안 forwardRef라는 복잡한 고차 컴포넌트(HOC)를 써야만 했다. 하지만 React 19가 등장하면서, 이 오랜 불편함이 드디어 역사 속으로 사라지게 되었다.
1. 과거의 유물: forwardRef의 불편함
React 18 버전까지는 자식 컴포넌트가 ref를 받으려면 반드시 forwardRef로 감싸줘야 했다.
// ⚠️ React 18 이하: forwardRef 필수
import { forwardRef } from 'react';
const CustomInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
return <input ref={ref} {...props} />;
});
// 컴포넌트 이름이 날아가서 디버깅할 때 displayName을 설정해야 하는 번거로움도 있었다.
CustomInput.displayName = 'CustomInput';이 방식은 여러모로 불편했다.
2. React 19의 혁명: ref는 이제 그냥 Prop이다
React 19 팀은 이 불합리함을 해결했다. 이제 ref는 특별한 예약어가 아니다. className이나 onClick처럼 그냥 props의 일부가 되었다.
이제 크리스는 forwardRef를 지우고, 함수 인자에서 ref를 바로 꺼내 쓸 수 있다.
// ✅ React 19: 그냥 받아서 쓰면 된다.
function CustomInput({ ref, ...props }: { ref: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}얼마나 아름다운가! 컴포넌트를 감싸던 껍데기가 사라지고, 코드는 직관적으로 변했다. 이것이 리액트 팀이 추구하는 "간결함"의 방향성이다.
3. useImperativeHandle: 부모에게 보여줄 것만 보여주기
ref를 전달하면 부모는 자식의 DOM 노드(HTMLInputElement)를 통째로 얻게 된다.
즉, 부모가 input.style.display = 'none'으로 자식을 맘대로 숨기거나, value를 강제로 바꿀 수도 있다는 뜻이다. 이는 캡슐화를 깨뜨리는 위험한 행동이다.
"부모에게 focus() 기능 딱 하나만 열어주고, 나머지는 감추고 싶은데?"
이때 사용하는 훅이 바로 useImperativeHandle이다. 이 훅은 부모가 받을 ref의 내용을 커스터마이징 할 수 있게 해준다.
구현 방법
useImperativeHandle을 사용하면 DOM 노드 대신, 우리가 만든 커스텀 객체(Handle)가 부모의 ref.current에 담기게 된다.
// 자식 컴포넌트
import { useImperativeHandle, useRef } from 'react';
// 1. 부모에게 노출할 메서드 타입 정의
export interface InputHandle {
focus: () => void;
shake: () => void; // 에러 났을 때 흔들기 애니메이션
}
function CustomInput({ ref, ...props }: { ref: React.Ref<InputHandle> }) {
const internalRef = useRef<HTMLInputElement>(null);
// 2. 부모의 ref에 내가 만든 객체를 연결(Handle)한다.
useImperativeHandle(ref, () => {
return {
// 실제 input DOM의 focus 메서드만 노출
focus: () => internalRef.current?.focus(),
// 나만의 커스텀 메서드 추가
shake: () => {
internalRef.current?.classList.add('shake');
setTimeout(() => internalRef.current?.classList.remove('shake'), 500);
}
};
});
return <input ref={internalRef} {...props} />;
}부모 컴포넌트에서의 사용
이제 부모는 자식의 input 태그 자체는 만질 수 없다. 오직 자식이 허락한 focus와 shake 함수만 사용할 수 있다.
// Parent.tsx
function Parent() {
const inputRef = useRef<InputHandle>(null);
const handleError = () => {
// DOM 조작은 불가능하지만, 정의된 기능은 사용 가능
inputRef.current?.focus();
inputRef.current?.shake();
};
return <CustomInput ref={inputRef} />;
}이 패턴은 비디오 플레이어 컴포넌트를 만들 때(play, pause만 노출)나, 복잡한 애니메이션 제어권을 부모에게 넘겨줄 때 유용하다.
4. 언제 써야 할까?
리액트의 대원칙은 "데이터는 단방향(부모 -> 자식)으로 흘러야 한다"는 것이다. ref를 통한 제어는 이 원칙을 거스르는 "탈출구(Escape Hatch)"다.
선언적인 방식(props)으로 해결할 수 있다면, 명령적인 방식(ref)은 피하는 것이 좋다.
핵심 정리
ref를 다루는 법이 훨씬 깔끔해졌다. React 19의 혁신은 여기서 끝나지 않는다.
useContext를 쓸 때마다 컴포넌트 상단에 훅을 선언해야 하는 제약도 사라진다. 심지어 조건문 안에서도 Context를 읽을 수 있다면?
"use API: useContext의 진화와 Promise(Suspense) 처리" 편에서 계속된다.