forwardRef 한줄 개념
- 문제: 함수형 컴포넌트는 기본적으로 ref를 받을 수 없어요(호스트 요소인 div, input 등만 기본 ref 지원).
- 해결: React.forwardRef는 부모가 만든 ref를 자식 컴포넌트 내부의 특정 노드/인스턴스에 “전달”(forward)할 수 있게 해주는 고차 컴포넌트(HOC) 입니다.
언제 필요한가?
부모가 자식 안쪽의 “실제 노드/인스턴스”를 만지기 위해 사용
- 스타일/레이아웃 래퍼 등 중간 껍데기 컴포넌트를 씌웠는데, 부모가 실제 DOM 노드(또는 R3F의 THREE.Mesh)에 접근해야 할 때
- 외부에서 focus(), scrollIntoView(), play() 같은 명령형(Imperative) 호출을 하고 싶은 컴포넌트
- R3F에서 부모가 자식 mesh의 position/rotation 등을 직접 만져야 할 때
시그니처와 타입
// React.forwardRef 시그니처(단순화)
function forwardRef<T, P = {}>(
render: (props: P, ref: React.Ref<T>) => React.ReactNode
): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<T>>
- T: ref가 가리킬 대상의 타입 (예: HTMLInputElement, THREE.Mesh, 사용자 정의 인퍼페이스 등)
- P: 컴포넌트 props 타입
- ref(매개변수) 역할/타입: React.Ref<T> (객체 ref, 콜백 ref 둘 다 가능)
- 반환값: ref를 받을 수 있는 컴포넌트(ForwardRefExoticComponent)
기본 예제 (DOM)
import React, { forwardRef, useRef } from 'react';
/**
* 설계 의도: Input 래퍼를 만들되, 부모가 focus()를 호출할 수 있게
* 구조 이유: wrapper <div>는 감추고, 실질 입력 노드(input)에 ref를 포워딩
*/
type FancyInputProps = {
label?: string; // 라벨 텍스트(옵션)
};
const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
({ label }, ref) => {
return (
<label style={{ display: 'block' }}>
{label}
<input ref={ref} placeholder="type here..." />
</label>
);
}
);
FancyInput.displayName = 'FancyInput'; // DevTools 가독성
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<FancyInput ref={inputRef} label="Name" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
}
- forwardRef((props, ref) => ...) 형식으로 ref를 두 번째 인자로 받아 내부 진짜 노드에 달아줍니다.
- 이제 부모는 자식 래퍼를 신경 안 쓰고 ref.current.focus()를 바로 사용 가능.
useImperativeHandle로 “API” 노출하기
forwardRef는 무엇이 ref로 노출되는지도 제어할 수 있어요. 내부 구현을 숨기고 제한된 명령형 API만 공개하고 싶을 때 사용합니다.
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
type PlayerHandle = {
play: () => void; // 외부에 공개할 메서드
pause: () => void;
};
type PlayerProps = { src: string };
const Player = forwardRef<PlayerHandle, PlayerProps>(({ src }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
// 설계 의도: 내부 video DOM은 감추고, play/pause만 노출
useImperativeHandle(ref, (): PlayerHandle => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
}), []);
return <video ref={videoRef} src={src} />;
});
export default function Demo() {
const ref = useRef<PlayerHandle>(null);
return (
<>
<Player ref={ref} src="/movie.mp4" />
<button onClick={() => ref.current?.play()}>Play</button>
<button onClick={() => ref.current?.pause()}>Pause</button>
</>
);
}
구조상의 이유: 외부에 “필요 최소한”의 명령만 노출하면 캡슐화가 좋아지고 리팩토링이 쉬워집니다.
콜백 ref와의 조합
const cbRef: React.RefCallback<HTMLInputElement> = (node) => {
if (node) node.focus(); // 마운트 시점 처리
};
<FancyInput ref={cbRef} />
- forwardRef는 객체 ref, 콜백 ref 둘 다 지원합니다.
- 콜백 ref는 마운트/언마운트 타이밍에 세밀하게 대응할 때 유용.
React.memo와 함께 쓰기
import React, { forwardRef, memo } from 'react';
const Button = memo(forwardRef<HTMLButtonElement, JSX.IntrinsicElements['button']>(
(props, ref) => <button ref={ref} {...props} />
));
// or
const _Button = forwardRef<HTMLButtonElement, Props>(...);
const Button2 = memo(_Button);
- 의도: 렌더 비용 절감(프롭스 변경 없으면 재렌더 회피)
- 주의: displayName을 설정해 DevTools에서 이름이 사라지지 않게
R3F(react-three-fiber)에서의 forwardRef
Three.js 객체(예: THREE.Mesh)에 대한 ref를 부모가 직접 다룰 수 있게 해줍니다.
import { forwardRef, useImperativeHandle, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
type SpinBoxHandle = {
spin: (speed?: number) => void;
};
type SpinBoxProps = {
color?: string;
};
export const SpinBox = forwardRef<SpinBoxHandle, SpinBoxProps>(({ color = 'orange' }, ref) => {
const meshRef = useRef<THREE.Mesh>(null);
let localSpeed = 0;
// 내부 애니메이션: 매 프레임 회전
useFrame((_, delta) => {
if (meshRef.current) meshRef.current.rotation.y += localSpeed * delta;
});
// 부모에 노출할 명령형 API
useImperativeHandle(ref, () => ({
spin: (speed = 1) => { localSpeed = speed; },
}), []);
return (
<mesh ref={meshRef}>
<boxGeometry />
<meshStandardMaterial color={color} />
</mesh>
);
});
// 사용
/*
const ref = useRef<SpinBoxHandle>(null);
<SpinBox ref={ref} />
<button onClick={() => ref.current?.spin(2)}>Spin faster</button>
*/
- 설계 의도: 부모가 자식 메시에 직접 접근하지 않고, 의미 있는 행위(spin)만 호출.
- 구조 이유: R3F에서 상태/애니메이션은 자식 내부에서 루프로 돌리고, 외부에는 API만 공개하면 결합도가 낮아짐.
폴리모픽 컴포넌트(고급, 선택)
as prop으로 태그를 바꾸는 컴포넌트의 ref 타입 유지(예: <Button as="a">)는 제네릭으로 해결합니다.
import React, { ElementType, forwardRef, ComponentPropsWithoutRef } from 'react';
type PolymorphicProps<T extends ElementType> = {
as?: T;
} & ComponentPropsWithoutRef<T>;
const Poly = forwardRef(<T extends ElementType = 'button'>(
{ as, ...props }: PolymorphicProps<T>,
ref: React.Ref<ElementRef<T>>
) => {
const Comp = as || 'button';
return <Comp ref={ref} {...props} />;
});
실무에서는 @radix-ui/@react-aria 패턴을 참고하면 좋아요.
자주 하는 실수 · 베스트 프랙티스
- forwardRef를 잊고 ref를 사용자 정의 컴포넌트에 직접 달기
→ ref가 null이거나 경고. 반드시 forwardRef로 감싸세요. - ref에 의존한 사이드이펙트를 render 중에 수행
→ 사이드이펙트는 useEffect/useLayoutEffect로. - 내부 구현을 그대로 ref로 노출
→ 나중에 구현 바꾸면 외부 코드가 다 깨져요. useImperativeHandle로 API를 좁게 설계하세요. - DevTools에서 이름이 ForwardRef만 보임
→ Component.displayName = '...' 설정. - StrictMode에서 mount/unmount 2회에 놀람
→ 개발 모드 특징. ref 콜백이 2번 불릴 수 있으니 idempotent하게 작성.
'JavaScript > React' 카테고리의 다른 글
| useDropzone, browser-image-compression [image upload 라이브러리] (6) | 2025.07.10 |
|---|---|
| Tiptap Editor (7) | 2025.06.26 |
| 파일 네이밍 컨벤션(File Naming Convention) - React Feature Folder (1) | 2025.06.14 |
| npm React 라이브러리 배포 (0) | 2025.06.13 |
| template 자동화 (0) | 2025.06.12 |