프론트엔드 작업을 하다보면 DOM 에 직접 접근하는 ref 속성을 사용하는 경우가 꽤 많이 온다.
DOM 이란?
Document Object Model의 약자로써 웹 페이지(HTML이나 XML 문서)의 콘텐츠 및 구조, 그리고 스타일 요소를 구조화 시켜 표현하여 프로그래밍 언어가 해당 문서에 접근하여 읽고 조작할 수 있도록 API를 제공하는 일종의 인터페이스이다. 즉 자바스크립트 같은 스크립팅 언어가 쉽게 웹 페이지에 접근하여 조작할 수 있게끔 연결시켜주는 역할을 담당한다.
HTML은 <div>등의 태그들로 이루어져 있는데, 이를 계층적으로 구조화하고 제어할 수 있도록 여러가지 메소드를 제공해주는 트리 자료 구조를 바로 DOM 이라고 할 수 있다. 아래는 DOM의 예시이다.
React로 작업을 할 때, state 만으로 해결할 수 없고, 반드시 해당 구조에 접근하여 작업을 해야하는 상황이 있다. 아래는 그 예시이다.
- 특정 input에 focus 주기
- 스크롤 박스 조작
위와 같은 상황 등에서 React에서는 useRef 라는 hook 을 사용하여 DOM에 직접 접근하여 제어할 수 있다.
기본적인 사용법은 아래와 같다.
const ref = useRef<HTMLDivElement>(null);
<div ref={ref}>
...
</div>
다음과 같이 useRef의 타입을 정의한 후, 해당 타입의 태그에 ref 속성을 부여하는 식으로 활용한다.
만약 디폴트 값으로 null을 빼면 다음과 같은 에러를 뿜어낸다.
Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'LegacyRef<HTMLInputElement> | undefined'.
에러의 원인을 본질적으로 확인하기 위해 @types/react 의 index.d.ts 파일로 가보면 useRef 훅에 3개의 정의가 오버로딩 되어있는 것을 확인할 수 있다.
useRef 훅 정의 1
function useRef<T>(initialValue: T): MutableRefObject<T>;
인자의 타입과 제네릭의 타입이 일치하는 경우, MutableRefObject<T> 를 반환한다.
MutableRefObject의 interface를 보면 아래와 같이 되어있다.
current가 readOnly가 아니므로 current 프로퍼티를 직접 변경할 수 있다.
useRef는 내용이 변경되어도 리렌더링을 trigger하지 않으므로 위의 정의를 잘 활용하면 로컬변수를 효과적으로 변경할 수 있다.
...
const ref = useRef<number>(0);
const handleButtonClick = () => {
if (ref.current) {
ref.current += 1;
}
};
return (
<button onClick={handleButtonClick}>{ref.current}</button>
);
위와 같이 제네릭에 number 타입을 주입하였고, 주입한 타입인 number와 같은 0을 디폴트 값으로 선언해 주었으므로 current 프로퍼티를 리렌더링 없이 수정할 수 있다.
useRef 훅 정의 2
function useRef<T>(initialValue: T | null): RefObject<T>;
제네릭의 타입이 주어졌을 때, 인자의 타입이 제네릭의 타입과 일치하거나 null일 때 RefObject<T> 를 반환한다.
RefObject의 interface를 보면 아래와 같이 되어있다.
readonly가 선언되어 있으므로 current 프로퍼티를 직접 수정하려는 시도를 하면 에러를 발생시키게 된다.
컴포넌트를 참조할 때 가장 많이 사용하는 패턴이다. (소개한 기본적인 사용법도 이 패턴!)
useRef 훅 정의 3
function useRef<T = undefined>(): MutableRefObject<T | undefined>;
제네릭의 타입이 T | undefined이고, 인자가 주어지지 않을 때 MutableRefObject<T | undefined>를 반환한다.
즉, 제네릭 타입이 선언되지 않으면 기본적으로 undefined를 주입해준다.
이제 위에서 인자로 null을 전달해주지 않았을 때 발생하는 에러의 원인을 알 수 있다.
기본적으로 컴포넌트를 참조할 때는 RefObject 형만 사용할 수 있는데, null을 선언해주지 않으면 정의 3대로 MutableRefObject를 사용하게 되어 에러가 발생하게 되는 것이다.
ref 동적으로 생성하기
프로젝트에서 미션이 주어졌다.
큐레이션에 담겨있는 장소에 맞게 장소의 이름이 담긴 버튼 리스트가 보여져야 했고, 해당 버튼을 누르면 해당 장소의 정보가 있는 곳으로 스크롤이 되어야했다.
즉 장소가 추가될 때마다 ref를 동적으로 생성해준 뒤, 장소의 정보를 보여주는 컴포넌트에 생성한 ref를 전달해 주어야 했다.
const refs = Array.from({ length: spaceDetails.length }, () =>
createRef<HTMLDivElement>()
);
const handlePlaceFilterClick = (index: number) => {
setPlaceIndex(index);
refs[index].current?.scrollIntoView({ behavior: "smooth" });
};
<div className="w-full items-start">
<div className="flex gap-[0.8rem] mb-[-10.6rem] overflow-x-scroll">
{spaceDetails.map((item, index) => (
<Filter
key={index}
photo={item.imageUrls && item.imageUrls[0]}
label={item.name}
selected={placeIndex === index}
className="whitespace-nowrap"
onClick={() => handlePlaceFilterClick(index)}
/>
))}
</div>
</div>
{spaceDetails.map((props, i) => (
<CurationDetailInfoCard
key={props.name}
curationId={curationId}
{...props}
ref={refs[i]}
/>
))}
</div>
해당 기능 코드의 일부이다.
spaceDetails 변수에는 큐레이션의 장소 정보 배열이 담겨있다.
refs에 장소 정보 배열의 길이만큼 createRef 훅을 통해 ref를 생성해주었다.
동적으로 ref 생성하기
createRef 훅을 통해 ref를 동적으로 생성해 낼 수 있다. 마찬가지로 index.d.ts 파일을 살펴보자.
function createRef<T>(): RefObject<T>;
인수는 전달할 필요 없이 선언해준 제네릭 타입과 같은 RefObject<T>를 반환해준다.
해당 코드에서 refs에는 RefObject<T>[] 와 같은 값이 저장되게 된다. 즉 동적으로 생성한 각 ref에 접근하기 위해서는 다른 배열과 마찬가지로 refs[0] 과 같이 index 로 접근하면 된다.
우선 map 함수를 통해 spaceDetails에 담긴 정보들을 이용하여 버튼 리스트를 생성한 뒤, 각 버튼의 index를 클릭하면 trigger되는 함수에 전달해주었다.
해당 함수에서는 전달받은 index로 refs 에 있는 하나의 ref를 컨트롤 하게 된다. scrollIntoView 메소드를 통해 해당 ref가 참조하고 있는 컴포넌트로 스크롤되게 된다.
마지막으로 map 함수를 통해 spaceDetails에 담긴 세부 정보를 보여줄 컴포넌트 리스트를 생성해주었다. refs 배열 중 각 컴포넌트의 index에 맞는 하나의 ref를 전달해주었다.
이때 ref는 다른 props와 달리 그냥 전달할 수가 없다.
ref를 prop 으로 전달하기
함수 컴포넌트에 props로 ref를 보내면 함수 컴포넌트에는 인스턴스가 없기 때문에 참조값이 null이 나오게 된다. ref를 prop으로 전달하기 위해서는 React에서 제공하는 forwardRef 훅을 사용해야 한다.
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, P>,
): ...
interface ForwardRefRenderFunction<T, P = {}> {
(props: P, ref: ForwardedRef<T>): ReactNode;
...
forwardRef 훅의 index.d.ts 파일을 보면 위와 같다. 내부가 굉장히 복잡하지만 중요하게 봐야 할 부분을 추렸다.
첫 번째 제네릭 타입 T는 전달할 ref 의 타입을, 두 번째 제네릭 타입 P는 ref를 제외한 다른 props의 타입을 나타낸다.
또한 ForwardRefRenderFunction 인터페이스를 보면 함수 자체를 인자로 넘겨야 하는 것을 볼 수 있다.
왜그런지 모르겠으나 넘기는 함수로 주는 인자의 순서는 위의 순서와 반대라는 것을 확인할 수 있다. (ref를 제외한 다른 props 먼저 넘김.)
const CurationDetailInfoCard = forwardRef<
HTMLDivElement,
CurationDetailInfoCardProps
>(({ ...props }, ref) => {
...
}
CurationDetailInfoCard.displayName = "CurationDetailInfoCard";
export default CurationDetailInfoCard;
정의된 구조에 맞게 forwardRef 훅을 써주자.
forwardRef 훅의 제네릭 타입으로 ref 가 참조할 컴포넌트의 타입과 받는 ref를 제외한 나머지 props 의 타입을 차레대로 넘겨준다. 인자로 넘기는 함수의 인자에는 순서를 반대로 선언해주었다.
Component definition is missing display name
해당 에러는 ESLint 사용 시 뜰 수 있는 에러인데 위의 코드처럼 displayName을 선언해주면 해결된다.
위처럼 해당 기능이 잘 동작하는 것을 확인할 수 있다!!
태그에 ref 여러개 선언하기
때로는 태그에 여러 ref를 선언해야 할 때도 있다.
스크롤 변화와 함께 clientWidth 값을 구해야 하는 상황이 그 예시이다.
다음 문제를 해결하기 위해 태그의 ref 속성 정의를 봐보자.
interface ClassAttributes<T> extends Attributes {
ref?: LegacyRef<T> | undefined;
}
type RefCallback<T> = { bivarianceHack(instance: T | null): void }["bivarianceHack"];
type Ref<T> = RefCallback<T> | RefObject<T> | null;
type LegacyRef<T> = string | Ref<T>;
위와 같이 ref 속성에는 RefObject<T> 타입도 들어갈 수 있지만, 콜백함수도 들어갈 수 있다는 것을 확인할 수 있다.
<div
className="w-full pt-[13rem]"
ref={(el) =>
assignMultipleRefs(el, [ref, ref2 as MutableRefObject<HTMLDivElement>])
}
>
위처럼 여러 ref를 전달하기 위해 ref 속성에 콜백함수를 넣어주었다.
el 에는 위에 정의된 것 처럼 T(위 코드에서는 HTMLDivElement) | null 인 인스턴스가 들어간다.
이를 커스텀 함수로 전달해야 하는데 커스텀 함수에서는 각 ref의 current를 조작해야 하므로 MutableRefObject<T> 타입으로 변환해주었다.
import { ForwardedRef, MutableRefObject } from "react";
export function assignMultipleRefs<U extends HTMLElement>(
el: U | null,
refs: (ForwardedRef<U> | MutableRefObject<U>)[]
) {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(el);
} else if (ref) {
ref.current = el;
}
});
}
ref들을 조작해 줄 커스텀 함수이다.
이때 ref 는 forwardRef 훅으로 전달받은 ref / useRef 훅으로 생성한 ref 둘 중 하나일 것이므로 refs 를 유니언 타입 배열로 정의해주었다.
이제 ForwardRef 타입의 정의를 봐보자.
type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
위 정의처럼 함수타입에 대비해, typeof ref === "function" 일 때와 아닐때의 경우를 구분하여 인스턴스를 인자로 전달하거나 ref.current 에 넣어주었다.
이처럼 ref 를 사용한 다양한 기능 구현에 대해 알아보았다. 보완할 점이 있다면 추가적인 공부를 통해 더 디벨롭 시키도록 해야겠다!!
'FrontEnd > React' 카테고리의 다른 글
서비스 내에 나만의 지도 띄우기 (Feat : NAVER MAP) (4) | 2024.02.27 |
---|---|
React-Transition-Group 으로 애니메이션 주입하기 (0) | 2024.02.18 |
스토리북(Storybook) 도입하기 (2) | 2024.02.05 |
Vite + Yarn berry 프로젝트 배포하기 (0) | 2023.12.24 |
Vite + Yarn Berry 구축기 - 2 (4) | 2023.12.07 |