부스트캠프 그룹 프로젝트 진행 중 모달 밖의 요소를 클릭했을 때 콜백 함수로 모달이 닫히는 커스텀 훅을 구현하여 사용했다.
찾아보니 ref 를 이용하여 좀 더 범용적으로 이용할 수 있었고, 커스텀 훅의 인자로 RefObject 를 입력하는 식으로 구현을 변경하였다.
이후 아래와 같은 오류가 발생하여 그 과정들을 기록하려고 한다.
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
DOM Element에 직접 접근할 수 있는 객체
ref
를 이용하면 직접적으로 자식 요소나 컴포넌트를 수정할 수 있다.ref
는 그 자체로 요소를 가리키는 상자📦 와 같다. 실제 요소는 ref.current
에서 관리한다.함수형 컴포넌트에서 변경 가능한 RefObject
를 반환하는 리액트 훅
const refContainer = useRef(initialValue);
공식 문서는 Ref를 사용해야 할 때로 다음과 같은 사례를 소개한다.
선언적으로 처리할 수 있다면 props를 이용해 선언적으로 처리하는 것을 권하고 있다.
혹은 상태와 달리 변경되어도 재렌더링 되지 않는 특성을 이용할 때 사용할 수 있다.
소스코드
// 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>
요소를 가리키고 있다.ref
객체는 상태와 달리 재렌더링이 일어나지 않는다.ref
객체의 current
를 변경하거나 수정해도 ref
객체가 변한 것으로 인식하지 않기 때문이다.ref.current
변경사항을 계속 추적해야 한다면?Element의 ref={...}
자리에 들어갈 수 있는 Ref 타입을 보면 RefCallback
타입이 있다.
type Ref<T> = RefCallback<T> | RefObject<T> | null
(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" />
프로젝트에서 모달을 구현했는데 생각보다 재사용하기 힘든 구조로 구현이 되어서 여러 래퍼런스를 찾아보았다.
leego.tistory.com 님 블로그에서 효율적인 모달 구현 과정을 굉장히 잘 풀어서 설명해주셔서 천천히 따라가면서 프로젝트에 적용했다.
적용 과정에서 props로 ref를 전달하는 로직이 생겼는데 그 때 오류가 발생했다.
모달 구현이 주 목적이 아니므로 자세한 구현은 생략한다.
상세 소스 코드는 예시 소스코드나 leego.tistory.com 님 블로그에서 확인하는 것을 추천한다.
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;
useOutsideClickHandler
커스텀 훅을 사용해 모달 외부가 눌리면 모달 창이 닫히도록 구현했다.
// 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;
Modal
에서는 위에서 정의한 커스텀 훅을 호출하여 이벤트를 등록한다.
// 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;
처음 구현했을 때
props
로ref={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 inundefined
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.
ref
는 key
속성과 마찬가지로 다른 용도로 사용된다고 한다.
또한 다른 공식 문서에 따르면 함수 컴포넌트는 인스턴스가 없기 때문에 함수 컴포넌트에 ref 어트리뷰트를 사용할 수 없습니다.
라는 문구가 있다.
그래서 위 예시의 props
로 ref
가 전달되지 않았다.
// 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
라는 일관성을 유지하기 어려워보인다.리액트에서는 forwardRef
함수를 통해 ref
에 대한 forwarding 기능을 제공한다.
forwardRef
로 컴포넌트를 감싸서 사용한다.forwardRef
로 감싸진 컴포넌트는 두번째 인자로 ref props
를 전달할 수 있다.아까 모달 문제를 이 방법을 통해 해결해보자.
// Modal/Modal.tsx
// ...
<ModalWrapper ref={modalRef}>
위와 같이 처음에 오류가 발생했던 것 처럼 ref props
로 modalRef
를 전달한다.
// 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
를 사용한 것은 아닐까? 생각도 들고, 제대로 동작하지 않을 수 있는데 일단 동작하니까 넘어간 기능도 있지 않을까? 라는 걱정도 된다.
앞으로 프로젝트 스프린트가 일주일 남았는데, 빠르게 목표를 달성하고 다시 한번 돌아봐야 한다고 느꼈다.