React의 forwardRef을 이용해 하위 컴포넌트의 element 참조하기 (Feat. callback ref)

이동현·2022년 6월 15일
8

ref

함수 컴포넌트를 구성하는 여러 element중 일부를 참조하기 위해 useRef() hook과 ref props를 이용합니다. 주로 focus 효과를 주거나, input에서 텍스트를 긁어오는 등의 목적으로 사용했던 경험이 있습니다.

const Component = () => {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, [])

  return (
    <div>
      <input ref={inputRef}/>
    </div>
  )
}

위의 코드에서 inputRef는 input element를 참조합니다. 개발자는 current 프로퍼티를 이용해 input에 접근 가능합니다.

forwardRef

문제 제기

const Parent = () => {
  return (
    <div>
      <Child/>
    </div>
  )
}

const Child = () => {
  return (
    <div>
      <input/>
    </div>
  )
}

위의 코드에서 Parent가 Child의 input에 접근하려면 어떻게 해야할까요? Parent에서 어떤 이벤트가 발생했을 때 Child의 input에 focus를 줘야한다면?

const Parent = () => {
  const childInputRef = useRef<HTMLInputElement>(null);
  
  return (
    <div>
      <Child inheritRef={childInputRef}/>
    </div>
  )
}

const Child = ({ inheritRef }: Props) => {
  return (
    <div>
      <input ref={inheritRef}/>
    </div>
  )
}

위와 같이 Child 컴포넌트에서 지정해준 Props 인터페이스 정의에 따라 Parent 컴포넌트에서 props를 통해 ref를 전달할 수 있습니다. 하지만, 리액트에서 공식적으로 ref라는 props를 지원하는데 굳이 다른 이름으로 전달할 수 밖에 없는 상황에 대해 의문을 품을 수 있을 것 같습니다.

forwardRef

위와 같은 상황을 해결하기 위해 리액트에서는 forwardRef를 제공합니다.

공식문서에서는 Ref Forwarding 기술을 아래와 같이 설명합니다.

(의역)
Ref forwarding은 자식 컴포넌트에게 ref를 넘겨주는 기술입니다.
대부분의 컴포넌트에서는 사용하지 않아도 되지만, 재사용 가능한 컴포넌트와 관해서는 유용하게 사용할 수 있습니다.

사용 방법은 아래와 같습니다.

const Parent = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  return (
    <div>
      <Child ref={inputRef}/>
    </div>
  )
}


const Child = React.forwardRef<HTMLInputElement>((_, inputRef) => {
  return (
    <div>
      <input ref={inputRef}/>
    </div>
  )
})

React의 정적 메서드인 forwardRef의 인자로 기존 컴포넌트를 넣어주면 됩니다.

말은 기존 컴포넌트라고 했지만, 약간 다른 부분이 있습니다. Child 컴포넌트에서의 매개변수 선언부를 보면 const Child = (_, inputRef) => {} 와 같은 형태로 두번째 매개변수를 명시했는데요, 해당 매개변수가 바로 부모 컴포넌트에서 ref props로 넘겨준 ref 변수가 됩니다. 전달받은 ref 변수를 특정 element랑 연결할 수 있습니다.

props와 함께 사용하기

만약, 해당 컴포넌트에서 props를 받기 위한 타입정의를 했다면 아래와 같이 코드를 작성하면 됩니다.

React.forwardRef<HTMLInputElement, Props>((props, ref) => {
// ...
})

3개 이상의 컴포넌트간 통신

문제 제기

마지막으로, 제가 forwardRef를 정리하고자 했던 이유이자 포스팅을 하게 된 이유랑 연관있는 내용입니다. forwardRef를 통해 3개 이상의 컴포넌트에 연달아 ref를 전달하는 경우에 대해서입니다.

const GrandParent = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  
  const handleInput = () => {
  	// inputRef의 값을 참조해서 사용하는 코드 작성
  }
  
  return (
    <div>
      <Parent ref={inputRef}/>
      <Button onClick={handleInput}/>
    </div>
  )
}

const Parent = React.forwardRef<HTMLInputElement>((_, inputRef) => {
  
  const handleInput = () => {
  	// inputRef의 값을 참조해서 사용하는 코드 작성
  }
  
  return (
    <div>
      <Child ref={inputRef}/>
      <Button onClick={handleInput}/>
    </div>
  )
})

const Child = React.forwardRef<HTMLInputElement>((_, inputRef) => {
  return (
    <div>
      <input ref={inputRef}/>
    </div>
  )
})

Child 컴포넌트의 input의 값을 Parent 및 GrandParent 컴포넌트에서 참조해야 하는 상황이 있었습니다. GrandParent 컴포넌트에서 input의 값을 참조하기 위해서는 평소 ref를 사용하는 것 처럼 쉽게 사용할 수 있었습니다.

// GrandParent
const handleInput = () => {
  const value = inputRef?.current?.value;
  // ...
}

Parent 컴포넌트에서는 위와 같은 방식으로 참조할 경우 아래와 같은 오류가 나타납니다.

Property 'current' does not exist on type '((instance: HTMLInputElement | null) => void) | MutableRefObject<HTMLInputElement | null>'.
Property 'current' does not exist on type '(instance: HTMLInputElement | null) => void'.

forwardRef의 인자로 들어갔던 컴포넌트의 두번째 매개변수인 inputRef는 (instance: HTMLInputElement | null) => void 라는 함수일 수 있고, MutableRefObject<HTMLInputElement | null>의 Ref Object일 수 있다는 것입니다. 만약 받은 인자가 함수의 형태라면 current 프로퍼티가 없으므로 예외를 던지는 상황입니다.

해결은 아래와 같이 할 수 있습니다.

// Parent
const handleInput = () => {
  if(typeof inputRef !== 'function') {
    const value = inputRef?.current?.value;
    // ...
  }
}

forwardRef의 타입

문제 해결은 되었지만, useRef() hook에서 반환하는 타입과 forwardRef에서 전달받는 ref의 타입이 사뭇 달라보입니다. 이것을 알아보고 싶었습니다.

forwardRef에서 전달받는 타입은 (instance: T) => voidMutableRefObject<T>이 Union으로 묶여있는 형태라는 것을 위의 케이스에서 볼 수 있었습니다. 즉, 둘 중 어떤것을 받아도 상관없다라는 의미로 해석할 수 있습니다.

useRef()에서 반환 가능한 값은 MutableRefObject<T>, RefObject<T>의 형태를 띄고 있습니다. 에디터에서 useRef를 클릭하고 F12를 누르면 타입 정의를 확인할 수 있는데요, 반환 타입은 아래와 같습니다.

function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T|null): RefObject<T>;
function useRef<T = undefined>(): MutableRefObject<T | undefined>;

즉, ref props로 넘겨준 값은 MutableRefObject 꼴이기 때문에, 위의 경우처럼 타입을 체크해 ref를 사용할 수 있었습니다.

(instance: T) => void와 같은 함수형 타입은 무엇일까요? 이것은 Callback Ref와 관련이 있습니다.

( 참고 RefObject 타입의 값도 ref props로 넘어가는 것을 확인했습니다. RefObject와 MutableRefObject의 차이는 내부의 current property가 RefObject의 경우 read-only라는 것 뿐입니다. 그런데 넘겨준 RefObject는 원래 current의 값을 변경하지 못하는게 맞다고 생각했는데 자식 컴포넌트에서 잘 바뀌더라고요..? 이 부분은 TS 언어적인 부분에 대한 미숙함때문에 생긴 궁금증인것 같아서 조금 더 공부해보려고 합니다.)

callback ref

일반적으로는 ref.current의 값이 변경되더라도 ref를 포함하고 있는 함수 컴포넌트를 다시 실행하지 않습니다. 리렌더링도 당연히 안됩니다. state나 props가 바뀌어 리렌더링이 되는 경우에만 ref에 저장되어있는 current 값을 이용해 화면을 다시 그릴 수 있습니다.

마찬가지로, ref가 특정 DOM element에 바인딩 되어도 컴포넌트가 리렌더링 되기 전까지는 상황을 알 수 없습니다.

const Component = () => {
	const ref = useRef<HTMLDivElement>(null);
  
  	console.log(ref.current) // undefined;
  
  	return <div ref={ref} />
}

예를 들어, 위와 같은 상황에서 ref에 div element를 바인딩했지만, ref.current의 변화는 컴포넌트 함수를 재호출하지 않기 때문에 컴포넌트 마운트 이후 별도의 메시지가 출력되지 않습니다.

마운트 이후에 ref로 받은 element를 이용한 작업을 수행해야 한다면 useEffect() hook을 이용해 동작을 정의할 수 있습니다.

const Component = () => {
	const ref = useRef<HTMLDivElement>(null);
  
  	useEffect(() =>  {
    	// ref를 참조한 연산
    }, []);
  
  	return <div ref={ref} />
}

리액트에서 제공하는 callback ref를 이용하면 보다 명료하고 깔끔하게 코드를 작성할 수 있습니다. ref props에 React.createRef()나 useRef()가 반환하는 값을 전달하는 것이 아니라, 접근하고 싶은 element를 매개변수로 가지는 콜백 함수를 ref props로 전달합니다.

const Component = () => {
  	const callbackRef = useCallback((node: HTMLDivElement) =>  {
    	// node를 참조한 연산
    }, []);
  
  	return <div ref={callbackRef} />
}

콜백함수의 매개변수로 들어온 node를 이용해, 컴포넌트가 렌더링 된 직후 동작을 정의할 수 있습니다. callback ref를 처음 들어봤기 때문에 또 어떤 활용을 할 수 있는지에 대해서는 더 알아봐야겠지만, 앞서 forward ref로 받을 수 있는 함수 형태의 타입이 callback 함수였다는 것은 알 수 있습니다.

마무리

저 역시 forwardRef을 알기 전까지는 자식 컴포넌트에서 정의했던 props명칭에 따라 ref를 전달하는 방식을 사용했었습니다. 이번에 해당 스펙을 처음 알게되었고, 앞으로는 통일성 있고 흐름에 맞는 코드를 작성하기 위해 많이 사용할 것 같습니다. 또한, forwardRef가 받는 인자의 타입에 의문을 가지면서 자연스레 callback ref에 대한 개념도 간단하게나마 익힐 수 있어 좋았습니다. 오히려 callback ref를 활용하는 코드를 앞으로 많이 작성하지 않을까 싶습니다.

이렇게 forwardRef에 대한 글을 작성했지만, 부모 컴포넌트에서 자식 컴포넌트 내부의 DOM을 직접 조작하는 것은 일반적인 컴포넌트 관계에서는 지양해야하는 패턴이라고 합니다. 여러 컴포넌트로 나누어 제작하는 것은 세부 기능을 숨기는 추상화를 하기위한 목적이 있기 때문인데, 다른 컴포넌트에서 자식 컴포넌트의 특성을 너무 쉽게 변경할 수 있다면 추상화가 잘 안된 컴포넌트이기 때문입니다. 오히려 이럴 경우에는 설계를 다시 고려해야 할 것 같습니다. (그런 측면에서, 3개의 컴포넌트를 forwardRef로 통신하려 했던 위와 같은 상황 자체에 문제가 있는 것 같네요.)

하지만, input이나 button처럼 외부에서 직접 DOM을 컨트롤하는 경우가 종종 있는데, 이럴 경우에 forwardRef을 사용해 관리하면 좋을 것 같습니다. 저도 이점에 염두해서 깔끔한 코드를 짤 수 있도록 항상 신경써야겠습니다.

참고 자료

profile
영차영차

1개의 댓글

comment-user-thumbnail
2023년 7월 22일

좋은 글 감사합니다

답글 달기