React useRef, forwardRef 활용하기 (feat. Function components cannot be given refs.)

Shyuuuuni·2022년 12월 8일
4

📚 Tech-Post

목록 보기
4/9
post-thumbnail

부스트캠프 그룹 프로젝트 진행 중 모달 밖의 요소를 클릭했을 때 콜백 함수로 모달이 닫히는 커스텀 훅을 구현하여 사용했다.

찾아보니 ref 를 이용하여 좀 더 범용적으로 이용할 수 있었고, 커스텀 훅의 인자로 RefObject 를 입력하는 식으로 구현을 변경하였다.

이후 아래와 같은 오류가 발생하여 그 과정들을 기록하려고 한다.
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

📦 ref, useRef 살펴보기

ref와 useRef Hook

ref

DOM Element에 직접 접근할 수 있는 객체

  • 리액트에서 일반적으로 부모-자식 컴포넌트간 상호작용은 props를 통해 이루어진다.
  • ref 를 이용하면 직접적으로 자식 요소나 컴포넌트를 수정할 수 있다.
  • ref 는 그 자체로 요소를 가리키는 상자📦 와 같다. 실제 요소는 ref.current에서 관리한다.

useRef()

함수형 컴포넌트에서 변경 가능한 RefObject를 반환하는 리액트 훅

const refContainer = useRef(initialValue);
  • refContainer: RefContainer
  • initialValue: refContainer.current 에 초기화 할 값

언제 사용하면 좋을까?

공식 문서는 Ref를 사용해야 할 때로 다음과 같은 사례를 소개한다.

  • 포커스, 텍스트 선택영역, 혹은 미디어의 재생을 관리할 때.
  • 애니메이션을 직접적으로 실행시킬 때.
  • 서드 파티 DOM 라이브러리를 React와 같이 사용할 때.

선언적으로 처리할 수 있다면 props를 이용해 선언적으로 처리하는 것을 권하고 있다.

혹은 상태와 달리 변경되어도 재렌더링 되지 않는 특성을 이용할 때 사용할 수 있다.

  • input 태그 focus, 텍스트 focus 등 렌더링에 영향이 없는 기능.
  • 성능 최적화

예시

소스코드

// App.tsx
const App = ():JSX.Element => {
  const appRef = useRef<HTMLDivElement | null>(null);

  const handleColorChange = () => {
    if (appRef.current === null) {
      return;
    }
    appRef.current.style.backgroundColor = 'pink';
  }

  return (
    <div className="App" ref={appRef}>
      <button onClick={handleColorChange}>색 바꾸기</button>
    </div>
  );
}

  • 버튼을 클릭하면 appRef 변수의 ref 객체에 직접 접근하여 해당 객체가 가리키는 요소에 직접 접근할 수 있다.
  • 위 예시에서는 appRef 객체가 appRef.current<div className="App" ref={appRef}>...</div> 요소를 가리키고 있다.
  • 해당 요소에 직접 접근하여 배경 색을 변경시킬 수 있다.

주의사항 (feat. callback ref)

  • 앞서 소개한 것처럼 ref 객체는 상태와 달리 재렌더링이 일어나지 않는다.
  • 왜냐하면 ref 객체의 current를 변경하거나 수정해도 ref 객체가 변한 것으로 인식하지 않기 때문이다.
  • 만약 ref.current 변경사항을 계속 추적해야 한다면?

Element의 ref={...} 자리에 들어갈 수 있는 Ref 타입을 보면 RefCallback 타입이 있다.

type Ref<T> = RefCallback<T> | RefObject<T> | null
  • Callback Ref 방식에 대해서는 따로 더 알아봐야 하겠지만 일반적으로 아래와 같이 (Node) => {...} 형식의 콜백 함수를 사용한다고 한다.
// 출처: https://tkdodo.eu/blog/avoiding-use-effect-with-callback-refs
// 직접 할당
<input
  ref={(node) => {
    node?.focus()
  }}
  defaultValue="Hello world"
/>

// useCallback 으로 할당
const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

🏃🏻 ForwardRef 를 사용하기 까지

프로젝트에서 모달을 구현했는데 생각보다 재사용하기 힘든 구조로 구현이 되어서 여러 래퍼런스를 찾아보았다.

leego.tistory.com 님 블로그에서 효율적인 모달 구현 과정을 굉장히 잘 풀어서 설명해주셔서 천천히 따라가면서 프로젝트에 적용했다.

적용 과정에서 props로 ref를 전달하는 로직이 생겼는데 그 때 오류가 발생했다.

문제 상황

모달 구현이 주 목적이 아니므로 자세한 구현은 생략한다.

상세 소스 코드는 예시 소스코드leego.tistory.com 님 블로그에서 확인하는 것을 추천한다.

useOutsideClickHandler.ts

import { RefObject, useEffect } from "react";

/**
 * ref 외부의 요소를 클릭했을 경우 실행할 콜백 함수를 등록합니다.
 */
const useOutsideClickHandler = (
  ref: RefObject<HTMLElement>,
  callback?: (event?: Event) => void
): void => {
  useEffect(() => {
    const handleClickOutside = (e: Event): void => {
      console.log("Handle Click!", ref.current);
      if (ref.current === null || ref.current.contains(e.target as Node)) {
        return;
      }
      callback?.(e); // 모달 외부 요소 클릭 시 실행
    };
    window.addEventListener("mousedown", handleClickOutside);
    return () => {
      window.removeEventListener("mousedown", handleClickOutside);
    };
  }, [ref, callback]);
};

export default useOutsideClickHandler;
  • ref: 내부 요소의 RefObject
  • callback: 내부 요소가 아닌 부분이 클릭되었을 때 실행할 콜백 함수

useOutsideClickHandler 커스텀 훅을 사용해 모달 외부가 눌리면 모달 창이 닫히도록 구현했다.

Modal.tsx

// Modal/Modal.tsx
const Modal = ({ title, onClose, children }: ModalProps): JSX.Element => {
  const modalRef = useRef<HTMLDivElement | null>(null);
  const handleModalClose = (): void => {
    onClose?.();
  };
  useOutsideClickHandler(modalRef, handleModalClose);

  return (
    <ModalContainer>
      <ModalOverlay>
        <InvalidModalWrapper ref={modalRef}>
          <ModalTitle title={title} />
          <ModalContents>{children}</ModalContents>
        </InvalidModalWrapper>
      </ModalOverlay>
    </ModalContainer>
  );
};

export default Modal;
  • ModalContainer, ModalOverlay, ...: 참고한 블로그에서는 StyledComponent를 사용했지만, 진행중인 프로젝트에서는 사용하지 않았기 때문에 각각의 컴포넌트로 분리했다.
  • InvalidModalWrapper: 모달 창을 감싸는 부분. (예시에서는 오류가 발생한 컴포넌트임을 표시하기 위해서 Invalid 전치사를 붙였다.)

Modal 에서는 위에서 정의한 커스텀 훅을 호출하여 이벤트를 등록한다.

InvalidModalWrapper

// ModalWrapper/InvalidModalWrapper.tsx
interface ModalWrapperProps {
  ref: RefObject<HTMLDivElement>;
  children: ReactNode;
}

const InvalidModalWrapper = ({ ref, children }: ModalWrapperProps): JSX.Element => {
  return (
    <div className="modal-wrapper" ref={ref}>
      {children}
    </div>
  );
};

export default InvalidModalWrapper;

처음 구현했을 때

propsref={modalRef}를 전달했으니까 컴포넌트에서 ref를 선언해주고 사용하면 되겠다.

라고 생각했다.

결과 - 오류

App.tsx 파일을 아래와 같이 수정하고 실행해보자.

// App.tsx
interface ModalProps {
  onClose: () => void;
}

const ModalA = ({ onClose }: ModalProps) => {
  const handleCloseModalA = () => {
    onClose();
    console.log("Modal A closed.");
  }
  return <Modal title={"Modal A"} onClose={handleCloseModalA}></Modal>
}

const ModalB = ({ onClose }: ModalProps) => {
  const handleCloseModalB = () => {
    onClose();
    console.log("Modal B closed.");
  }
  return <Modal title={"Modal B"} onClose={handleCloseModalB}></Modal>
}

const App = ():JSX.Element => {
  const appRef = useRef<HTMLDivElement | null>(null);
  const [visibleModalA, setVisibleModalA] = useState(false);
  const [visibleModalB, setVisibleModalB] = useState(false);

  const handleColorChange = () => {
    if (appRef.current === null) {
      return;
    }
    appRef.current.style.backgroundColor = 'pink';
  }

  return (
    <div className="App" ref={appRef}>
      <button onClick={handleColorChange}>색 바꾸기</button>
      <button onClick={() => setVisibleModalA(true)}>모달A 열기</button>
      <button onClick={() => setVisibleModalB(true)}>모달B 열기</button>
      {visibleModalA && <ModalA onClose={() => setVisibleModalA(false)}/>}
      {visibleModalB && <ModalB onClose={() => setVisibleModalB(false)} />}
    </div>
  );
}

export default App;

  • Warning: InvalidModalWrapper: ref is not a prop. Trying to access it will result in undefined being returned. If you need to access the same value within the child component, you should pass it as a different prop.
  • Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

리액트 공식 문서에서 관련 설명이 있다.

... refs will not get passed through. That’s because ref is not a prop. Like key, it’s handled differently by React.

refkey 속성과 마찬가지로 다른 용도로 사용된다고 한다.

또한 다른 공식 문서에 따르면 함수 컴포넌트는 인스턴스가 없기 때문에 함수 컴포넌트에 ref 어트리뷰트를 사용할 수 없습니다. 라는 문구가 있다.

그래서 위 예시의 propsref가 전달되지 않았다.

문제 해결

1. props명 변경

// Modal/Modal.tsx
const Modal = ({ title, onClose, children }: ModalProps): JSX.Element => {
  const modalRef = useRef<HTMLDivElement | null>(null);
  const handleModalClose = (): void => {
    onClose?.();
  };
  useOutsideClickHandler(modalRef, handleModalClose);

  return (
    <ModalContainer>
      <ModalOverlay>
        <InvalidModalWrapper modalRef={modalRef}>
          <ModalTitle title={title} />
          <ModalContents>{children}</ModalContents>
        </InvalidModalWrapper>
      </ModalOverlay>
    </ModalContainer>
  );
};

export default Modal;

// ModalWrapper/InvalidModalWrapper.tsx
interface ModalWrapperProps {
  modalRef: RefObject<HTMLDivElement>;
  children: ReactNode;
}

const InvalidModalWrapper = ({ modalRef, children }: ModalWrapperProps): JSX.Element => {
  return (
    <div className="modal-wrapper" ref={modalRef}>
      {children}
    </div>
  );
};

export default InvalidModalWrapper;
  • ref 라는 이름이 props에서 찾을 수 없다고 하므로 다른 이름을 사용하면 해결 할 수 있다.
  • 하지만 ref 라는 일관성을 유지하기 어려워보인다.
  • 더 좋은 방법이 없을까?

2. 🎯 forwardRef()

리액트에서는 forwardRef 함수를 통해 ref에 대한 forwarding 기능을 제공한다.

  • forwardRef 로 컴포넌트를 감싸서 사용한다.
  • forwardRef 로 감싸진 컴포넌트는 두번째 인자로 ref props 를 전달할 수 있다.

아까 모달 문제를 이 방법을 통해 해결해보자.

// Modal/Modal.tsx
// ...
<ModalWrapper ref={modalRef}>

위와 같이 처음에 오류가 발생했던 것 처럼 ref propsmodalRef를 전달한다.

// ModalWrapper/ModalWrapper.tsx
interface ModalWrapperProps {
  children: ReactNode;
}

const ModalWrapper = forwardRef<HTMLDivElement, ModalWrapperProps>(
  ({ children }, ref): JSX.Element => {
    return (
      <div className="modal-wrapper" ref={ref}>
        {children}
      </div>
    );
  }
);

export default ModalWrapper;

ModalWrapper 컴포넌트를 forwardRef 함수로 감싸서 선언한다.

  • 타입으로는 ref 요소의 타입과 props 타입을 제네릭으로 선언한다.
  • 컴포넌트의 두번째 인자에서 ref 값을 받아온다.
  • 해당 ref 값을 부모 컴포넌트에서 ref props로 전달한 RefObject로 사용할 수 있다.

결론

다른 기술들 처럼 그동안 ref 에 대해서 제대로 알아보지 않고 많이 사용했던 것 같아서 반성했다.

지금까지 사용한 ref 중에서 리액트에서 추천하는데로 선언적으로 프로그래밍 할 수 있었는데 ref를 사용한 것은 아닐까? 생각도 들고, 제대로 동작하지 않을 수 있는데 일단 동작하니까 넘어간 기능도 있지 않을까? 라는 걱정도 된다.

앞으로 프로젝트 스프린트가 일주일 남았는데, 빠르게 목표를 달성하고 다시 한번 돌아봐야 한다고 느꼈다.

래퍼런스

profile
배짱개미 개발자 김승현입니다 🖐

0개의 댓글