forwardRef와 useImperativeHandle은 무엇이고 React에서 언제 써야 할까
React에서 ref를 처음 배우면 보통 이렇게 이해합니다.
- DOM 요소에 직접 접근할 수 있다
input에 포커스를 줄 수 있다- 스크롤 위치를 조절할 수 있다
여기까지는 비교적 단순합니다. 그런데 컴포넌트를 나누기 시작하면 질문이 조금 달라집니다.
- 부모가 자식 컴포넌트 내부 input에 포커스를 주고 싶다면?
- 자식이 가진
open,close,clear같은 동작만 제한적으로 노출하고 싶다면?
이 지점에서 forwardRef와 useImperativeHandle이 등장합니다.
한눈에 보면
짧게 정리하면 이렇습니다.
forwardRef: 부모가 넘긴 ref를 자식 컴포넌트 안쪽으로 전달할 수 있게 해줍니다.useImperativeHandle: 그 ref를 통해 노출할 값을 직접 제어하게 해줍니다.
즉:
- 단순히 내부 DOM을 그대로 노출하고 싶다면
forwardRef - DOM 전체가 아니라 제한된 imperative API만 노출하고 싶다면
useImperativeHandle
먼저 ref 기본부터
가장 단순한 ref는 DOM 요소를 직접 가리킵니다.
import { useRef } from 'react';
export function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleFocus = () => {
inputRef.current?.focus();
};
return (
<div>
<input ref={inputRef} />
<button onClick={handleFocus}>포커스</button>
</div>
);
}이 단계에서는 ref가 같은 컴포넌트 안에 있으니 어렵지 않습니다.
컴포넌트 경계를 넘기 시작하면 왜 막힐까?
문제는 input을 별도 컴포넌트로 분리하는 순간 생깁니다.
function TextField() {
return <input />;
}
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return <TextField ref={inputRef} />;
}이 코드는 그대로는 동작하지 않습니다. 함수 컴포넌트는 기본적으로 ref를 그냥 prop처럼 받지 않기 때문입니다. 부모가 넘긴 ref를 실제 DOM에 연결하려면 자식 쪽에서 명시적으로 ref 전달을 허용해야 합니다.
forwardRef는 무엇일까?
forwardRef는 이름 그대로 ref를 앞으로 전달하는 도구입니다.
import { forwardRef } from 'react';
type TextFieldProps = {
label: string;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
{ label },
ref
) {
return (
<label>
<span>{label}</span>
<input ref={ref} />
</label>
);
});이제 부모는 자식 내부의 input에 접근할 수 있습니다.
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<TextField ref={inputRef} label="검색어" />
<button onClick={() => inputRef.current?.focus()}>포커스</button>
</>
);
}핵심은 이겁니다. forwardRef는 부모가 넘긴 ref를 자식 안쪽의 특정 DOM이나 컴포넌트 인스턴스로 연결해주는 통로입니다.
그런데 DOM을 그대로 노출하는 게 항상 좋을까?
여기서 한 번 더 생각할 지점이 있습니다.
부모가 자식 내부 DOM을 전부 알게 되면 결합도가 높아질 수 있습니다.
- 부모가
input.value를 직접 만지기 시작할 수 있고 - 내부 구조가 바뀌면 부모도 같이 깨질 수 있고
- 자식이 무엇을 공개 API로 보장하는지 불분명해질 수 있습니다
즉, 부모가 정말 필요한 것은 "DOM 전체"가 아니라 focus, clear, open, close 같은 제한된 기능일 수 있습니다.
이럴 때 쓰는 것이 useImperativeHandle입니다.
useImperativeHandle은 무엇일까?
useImperativeHandle은 ref로 노출할 값을 직접 정의하게 해줍니다.
import { forwardRef, useImperativeHandle, useRef } from 'react';
type InputHandle = {
focus: () => void;
clear: () => void;
};
type SearchFieldProps = {
placeholder?: string;
};
export const SearchField = forwardRef<InputHandle, SearchFieldProps>(function SearchField(
{ placeholder },
ref
) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus() {
inputRef.current?.focus();
},
clear() {
if (inputRef.current) {
inputRef.current.value = '';
}
},
}),
[]
);
return <input ref={inputRef} placeholder={placeholder} />;
});부모는 이제 DOM 자체가 아니라 명시된 API만 사용할 수 있습니다.
function Parent() {
const searchFieldRef = useRef<InputHandle>(null);
return (
<>
<SearchField ref={searchFieldRef} placeholder="검색어를 입력하세요" />
<button onClick={() => searchFieldRef.current?.focus()}>포커스</button>
<button onClick={() => searchFieldRef.current?.clear()}>초기화</button>
</>
);
}이 구조가 더 좋은 이유는 자식 컴포넌트의 공개 계약이 분명해지기 때문입니다.
input 예제로 보면 언제 유용할까?
아래 상황에서 특히 유용합니다.
- 복합 input 컴포넌트 안에 실제 focus 대상이 숨겨져 있을 때
- 부모는 오직
focus,clear,select정도만 필요할 때 - 내부 구조가 바뀌어도 외부 API는 유지하고 싶을 때
예를 들어 검색 input 내부가 나중에 단순 <input />에서 마스킹 컴포넌트나 서드파티 입력 컴포넌트로 바뀌더라도, 부모는 여전히 focus()만 호출하면 됩니다.
modal 예제로 보면 더 분명하다
Modal은 useImperativeHandle을 설명하기 좋은 예제입니다.
import { forwardRef, useImperativeHandle, useState } from 'react';
type ModalHandle = {
open: () => void;
close: () => void;
};
export const ConfirmModal = forwardRef<ModalHandle>(function ConfirmModal(_, ref) {
const [isOpen, setIsOpen] = useState(false);
useImperativeHandle(
ref,
() => ({
open() {
setIsOpen(true);
},
close() {
setIsOpen(false);
},
}),
[]
);
if (!isOpen) return null;
return (
<div>
<p>정말 삭제하시겠습니까?</p>
<button onClick={() => setIsOpen(false)}>닫기</button>
</div>
);
});부모는 이렇게 사용할 수 있습니다.
function Page() {
const modalRef = useRef<ModalHandle>(null);
return (
<>
<button onClick={() => modalRef.current?.open()}>모달 열기</button>
<ConfirmModal ref={modalRef} />
</>
);
}이 패턴은 편리하지만, 동시에 "선언적 흐름을 우회하는 제어"라는 점도 기억해야 합니다.
선언적 제어와 imperative 제어를 어떻게 구분할까?
여기서 가장 중요한 판단 기준이 나옵니다.
선언적 제어가 더 맞는 경우
isOpen,value,selectedId같은 상태를 상위에서 명시적으로 관리해야 할 때- 화면 상태가 URL, 서버 데이터, 다른 컴포넌트와 연결될 때
- 테스트와 추론 가능성이 중요할 때
예를 들면 보통 modal open 여부는 아래처럼 선언적으로 다루는 편이 더 읽기 쉽습니다.
const [isOpen, setIsOpen] = useState(false);
<Modal open={isOpen} onOpenChange={setIsOpen} />;imperative 제어가 더 맞는 경우
- 포커스 이동, 스크롤 조정, 텍스트 선택처럼 DOM 제어가 목적일 때
- 공개 API를
focus,open,clear처럼 아주 제한적으로 노출하고 싶을 때 - 부모가 "명령"을 보내는 편이 더 자연스러운 상호작용일 때
즉, forwardRef와 useImperativeHandle은 강력하지만, 기본 선택이라기보다 선언적으로 풀기 애매한 제어 지점을 다루는 도구라고 보는 편이 맞습니다.
TypeScript에서는 어떻게 타입을 잡을까?
중요한 포인트는 두 가지입니다.
1. DOM을 그대로 노출할 때
forwardRef<HTMLInputElement, TextFieldProps>(...)이렇게 DOM 타입을 직접 지정합니다.
2. imperative handle을 노출할 때
type ModalHandle = {
open: () => void;
close: () => void;
};
forwardRef<ModalHandle, ModalProps>(...)즉, ref가 가리키는 대상이 DOM이 아니라 "공개 API 객체"가 됩니다.
정리하면
forwardRef와 useImperativeHandle을 한 줄로 요약하면 이렇습니다.
forwardRef: 부모의 ref를 자식 내부로 전달하는 도구useImperativeHandle: 그 ref를 통해 노출할 API를 제한해서 정의하는 도구
실무 기준으로 다시 줄이면:
- 내부 input에 포커스를 넘기고 싶다면
forwardRef - DOM 전체 대신
focus,clear,open,close만 노출하고 싶다면useImperativeHandle - 하지만 상태 제어 자체는 여전히 선언적으로 푸는 것이 기본값
결국 중요한 것은 ref 자체가 아니라, 부모가 자식 내부에 대해 무엇을 알아도 되는가입니다. 그 경계를 분명하게 만들고 싶을 때 useImperativeHandle은 꽤 좋은 안전장치가 됩니다.
