10.4 Ref와 forwardRef — useRef<T>와 제네릭 forwardRef
useRef의 두 가지 용도
useRef는 두 가지 목적으로 사용됩니다.
- DOM 요소 참조:
null로 초기화하고, 제네릭에 HTML 요소 타입 지정 - 뮤터블 값 저장: 리렌더링 없이 값을 유지
DOM 요소 참조
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
// DOM 참조: 초기값 null, 타입은 HTMLInputElement
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // ?. 사용 (null일 수 있음)
}, []);
return <input ref={inputRef} placeholder="자동 포커스" />;
}
주요 HTML 요소 타입
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectRef = useRef<HTMLSelectElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const ulRef = useRef<HTMLUListElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
DOM 조작 예시
function ScrollableList() {
const listRef = useRef<HTMLUListElement>(null);
const scrollToTop = () => {
listRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
};
const getHeight = (): number => {
return listRef.current?.offsetHeight ?? 0;
};
return (
<>
<ul ref={listRef} style={{ height: '300px', overflow: 'auto' }}>
{Array.from({ length: 100 }, (_, i) => (
<li key={i}>항목 {i + 1}</li>
))}
</ul>
<button onClick={scrollToTop}>맨 위로</button>
</>
);
}
뮤터블 값 저장
import { useRef, useState, useEffect } from 'react';
// 이전 값 기억
function usePrevious<T>(value: T): T | undefined {
const prevRef = useRef<T>();
useEffect(() => {
prevRef.current = value;
}, [value]);
return prevRef.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>현재: {count}, 이전: {prevCount ?? '없음'}</p>
<button onClick={() => setCount(c => c + 1)}>증가</button>
</div>
);
}
// 타이머 ID 저장
function Debounce() {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [value, setValue] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
console.log('검색:', e.target.value);
}, 300);
};
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return <input value={value} onChange={handleChange} />;
}
RefCallback (콜백 Ref)
함수를 ref로 사용할 때의 타입입니다.
import { RefCallback } from 'react';
function MeasureElement() {
const [height, setHeight] = useState(0);
// RefCallback<HTMLDivElement>
const measuredRef: RefCallback<HTMLDivElement> = (node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
};
return (
<div>
<div ref={measuredRef}>측정할 내용</div>
<p>높이: {height}px</p>
</div>
);
}
forwardRef — Ref를 자식 컴포넌트로 전달
부모 컴포넌트에서 자식의 DOM 요소에 접근해야 할 때 사용합니다.
기본 forwardRef
import { forwardRef, useImperativeHandle } from 'react';
interface InputProps {
label: string;
placeholder?: string;
}
// forwardRef<RefType, PropsType>
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, placeholder }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} placeholder={placeholder} />
</div>
);
}
);
Input.displayName = 'Input'; // DevTools에 표시될 이름
// 사용
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<div>
<Input ref={inputRef} label="이름" placeholder="이름 입력" />
<button onClick={focusInput}>포커스</button>
</div>
);
}
useImperativeHandle — 노출할 메서드 제어
DOM 전체를 노출하지 않고, 특정 메서드만 부모에게 제공합니다.
import { forwardRef, useImperativeHandle, useRef } from 'react';
// 노출할 메서드 타입 정의
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (seconds: number) => void;
getCurrentTime: () => number;
}
interface VideoPlayerProps {
src: string;
autoPlay?: boolean;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
({ src, autoPlay = false }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
// 부모에게 노출할 메서드 정의
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = seconds;
}
},
getCurrentTime: () => videoRef.current?.currentTime ?? 0,
}));
return <video ref={videoRef} src={src} autoPlay={autoPlay} />;
}
);
// 사용
function VideoPage() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current?.play()}>재생</button>
<button onClick={() => playerRef.current?.pause()}>일시정지</button>
<button onClick={() => playerRef.current?.seek(30)}>30초로 이동</button>
</div>
);
}
제네릭 forwardRef (고급)
TypeScript에서 forwardRef는 제네릭을 제대로 지원하지 않습니다. 이를 해결하는 패턴입니다.
// 문제: forwardRef에서 제네릭이 동작하지 않음
const List = forwardRef(<T,>(
{ items }: { items: T[] },
ref: React.ForwardedRef<HTMLUListElement>
) => (
<ul ref={ref}>
{items.map((item, i) => <li key={i}>{String(item)}</li>)}
</ul>
));
// 해결 1: 타입 단언
const TypedForwardRef = forwardRef as <T>(
render: (props: { items: T[] }, ref: React.ForwardedRef<HTMLUListElement>) => React.ReactElement | null
) => (props: { items: T[] } & React.RefAttributes<HTMLUListElement>) => React.ReactElement | null;
// 해결 2: 함수 오버로딩 (더 실용적)
function createForwardRef<T>(
render: (props: ListProps<T>, ref: React.ForwardedRef<HTMLUListElement>) => React.ReactElement | null
) {
return forwardRef(render) as unknown as <U extends T>(
props: ListProps<U> & React.RefAttributes<HTMLUListElement>
) => React.ReactElement | null;
}
고수 팁
useRef 타입 구분: null vs undefined 초기값
// null 초기값: DOM 참조용 (RefObject<T>)
const domRef = useRef<HTMLDivElement>(null);
// domRef.current: HTMLDivElement | null
// undefined 초기값: 뮤터블 값용 (MutableRefObject<T>)
const valueRef = useRef<number>();
// valueRef.current: number | undefined
// 값 있는 초기화: 뮤터블 값용
const timerRef = useRef<number>(0);
// timerRef.current: number (null 없음)
null로 초기화하면 RefObject(읽기 전용처럼 사용)가 되고, undefined나 값으로 초기화하면 MutableRefObject(쓰기 가능)가 됩니다.