본문 바로가기
JavaScript/React

forwardRef

by curious week 2025. 8. 18.

 

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 패턴을 참고하면 좋아요.


자주 하는 실수 · 베스트 프랙티스

  1. forwardRef를 잊고 ref를 사용자 정의 컴포넌트에 직접 달기
    → ref가 null이거나 경고. 반드시 forwardRef로 감싸세요.
  2. ref에 의존한 사이드이펙트를 render 중에 수행
    → 사이드이펙트는 useEffect/useLayoutEffect로.
  3. 내부 구현을 그대로 ref로 노출
    → 나중에 구현 바꾸면 외부 코드가 다 깨져요. useImperativeHandle로 API를 좁게 설계하세요.
  4. DevTools에서 이름이 ForwardRef만 보임
    → Component.displayName = '...' 설정.
  5. StrictMode에서 mount/unmount 2회에 놀람
    → 개발 모드 특징. ref 콜백이 2번 불릴 수 있으니 idempotent하게 작성.