React|React Hook 톱아보기

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

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

크리스가 회원가입 폼을 만들고 있다.

"이메일 중복 확인" 버튼을 눌렀을 때 이미 가입된 이메일이라면, 이메일 입력창(CustomInput)에 빨간 불이 들어오고 자동으로 포커스(Focus)가 잡히게 하고 싶다.

부모 컴포넌트에서 자식의 input에 접근해야 하니, 크리스는 배운 대로 ref를 넘겨주기로 했다.

typescript
// 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로 감싸줘야 했다.

typescript
// ⚠️ React 18 이하: forwardRef 필수
import { forwardRef } from 'react';

const CustomInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />;
});

// 컴포넌트 이름이 날아가서 디버깅할 때 displayName을 설정해야 하는 번거로움도 있었다.
CustomInput.displayName = 'CustomInput';

이 방식은 여러모로 불편했다.

  • 가독성 저하: 함수 하나를 더 감싸야 하니 코드가 깊어진다(Indent).
  • 제네릭 타입 지옥: 타입스크립트에서 forwardRef의 제네릭 순서와 타입을 맞추는 건 꽤나 골치 아픈 일이었다.
  • 멘탈 모델 불일치: "컴포넌트는 그냥 함수일 뿐인데, 왜 ref만 특별 대우를 받아야 하지?"라는 의문이 남는다.
  • 2. React 19의 혁명: ref는 이제 그냥 Prop이다

    React 19 팀은 이 불합리함을 해결했다. 이제 ref는 특별한 예약어가 아니다. className이나 onClick처럼 그냥 props의 일부가 되었다.

    이제 크리스forwardRef를 지우고, 함수 인자에서 ref를 바로 꺼내 쓸 수 있다.

    typescript
    // ✅ 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에 담기게 된다.

    typescript
    // 자식 컴포넌트
    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 태그 자체는 만질 수 없다. 오직 자식이 허락한 focusshake 함수만 사용할 수 있다.

    typescript
    // 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)은 피하는 것이 좋다.

    핵심 정리

  • React 19의 변화: forwardRef는 은퇴했다. 이제 ref는 일반 props처럼 함수 인자에서 바로 구조 분해 할당하여 사용할 수 있다.
  • useImperativeHandle: 부모에게 DOM 노드 전체를 넘기는 것이 부담스러울 때, 원하는 함수만 골라서 노출하는 통제된 API를 만들 수 있다.
  • 캡슐화: 이 훅을 사용하면 자식 컴포넌트의 내부 구현(DOM 구조 등)을 숨기면서도 필요한 제어권만 부모에게 안전하게 위임할 수 있다.
  • ref를 다루는 법이 훨씬 깔끔해졌다. React 19의 혁신은 여기서 끝나지 않는다.

    useContext를 쓸 때마다 컴포넌트 상단에 훅을 선언해야 하는 제약도 사라진다. 심지어 조건문 안에서도 Context를 읽을 수 있다면?

    "use API: useContext의 진화와 Promise(Suspense) 처리" 편에서 계속된다.


    🔗 참고 링크

  • React 19 Beta Blog Post
  • React Docs - Manipulating the DOM with Refs
  • React Docs - useImperativeHandle
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...