개발 중 ref 객체를 자식 컴포넌트에 전달하는 과정에서 에러를 겪었습니다.
이왕 정리할 거 ref에 대하여 한번에 다 정리하자 싶어서 작성합니다.
ref란?
크게 2가지 용도로 ref를 사용할 수 있습니다.
컴포넌트가 일부 정보를 기억하고 싶게 하고싶지만 리렌더링을 유발하게 하고 싶지 않을 때
DOM 요소에 직접 접근하고 싶을 때
이번 포스트에서는 2번 용도에 대한 내용입니다. 1번에 대해서는 기회가 되면 다뤄보겠습니다.
Ref로 DOM 조작하기
보통 React로 개발을 할 때 DOM을 직접 조작할 일은 많지 않습니다. (React가 자동으로 해주니까요!)
하지만 가끔 직접적인 DOM 접근이 필요합니다. 이번 같은 경우는 특정 노드에 포커스를 옮기는 경우입니다.
예시 코드를 통해 보겠습니다.
import { useRef } from 'react';
export default function Form() {
// 1. inputRef 선언
const inputRef = useRef(null);
function handleClick() {
// 3. inputRef.current에서 DOM 노드를 읽고 inputRef.current.focus()로 focus()를 호출
inputRef.current.focus();
}
return (
<>
// 2. React에 이 <input>의 DOM 노드를 inputRef.current에 넣어달라고 요청
<input ref={inputRef}/>
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
여기까진 간단합니다.
하지만 다른 컴포넌트로 ref를 전달할 때는 일반적인 prop처럼 넘겨줘선 안됩니다.
ForwardRef
정말 귀찮습니다.. 😫
왜 그냥 일반 프로퍼티처럼 ref를 넘겨주면 에러가 발생하도록 해놓았을까요?
리액트는 기본적으로 다른 컴포넌트의 DOM 노드에 접근하는 것을 막습니다. 다만 예외적으로 자식 중 하나에 ref를 전달할 수 있게 지정할 수 있는데, 이것이 forwardRef
API입니다.
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
이제 이렇게 ref를 넘겨줄 수 있게 되었습니다.
In TypeScript?
위 예제들은 JavaScript의 예시였습니다. 실제 개발 상황에서는 TypeScript를 쓸겁니다. ref의 타입 지정은 어떻게 해야 할까요?
RefObject
interface RefObject<T> {
readonly current: T | null;
}
import { useRef } from 'react';
export const MyComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.focus();
};
return (
<div>
<input type='text' ref={inputRef} />
<button onClick={handleClick}>Focus Input</button>
</div>
);
};
useRef
의 리턴 타입입니다. RefObject
는 readonly입니다. current
프로퍼티를 변경할 수 없습니다. 만일 초기 값으로 null
을 전달하게 되면 TypeScript는 자동으로 RefObject
로 인식합니다. 만일 MutableRefObject
이면서 초기 값이 null
이길 원한다면, 아래와 같이 간단히 정의 가능합니다.
const inputRef = useRef<HTMLInputElement | null>(null);
MutableRefObject
위에서 다룬 RefObject
와 유사하지만, 이름에서 유추할 수 있다시피 current
프로퍼티가 변경 가능합니다. ref 값을 직접 바꿔야 할 때 유용합니다.
interface MutableRefObject<T> {
current: T;
}
import { useRef } from 'react';
export const MyComponent = () => {
const counterRef = useRef<number>(0);
const handleClick = () => {
counterRef.current++;
console.log('Counter value:', counterRef.current++);
};
return (
<div>
<button onClick={handleClick}>Increment Counter</button>
</div>
);
};
ForwardedRef
부모 컴포넌트에서 자식 컴포넌트로 ref를 전달할 때 쓰입니다.
type ForwardedRef<T> =
| ((instance: T | null) => void)
| MutableRefObject<T | null>
| null;
import { forwardRef, Ref } from 'react';
interface InputProps {
value: string;
onChange: (value: string) => void;
}
const InputBase = ({ value, onChange }: InputProps, ref: React.ForwardedRef<HTMLInputElement>) => {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
ref={ref}
/>
);
});
export const Input = forwardRef(InputBase);
ref를 전달받은 자식 컴포넌트 입장에서는 이 ref가 정확히 어떤 타입인지 알 수 없습니다. 따라서 타입 에러를 피하기 위해서는, 상단의 JS 예시와는 다르게 전달받은 ref의 타입을 검증하는 로직이 추가적으로 필요합니다.
// 이런 모습이 될 겁니다.
if (!ref) return;
if (typeof ref === 'function') {
ref(innerRef.current);
} else {
ref.current = innerRef.current;
}
외전 - createRef
와 useRef
의 차이
가끔 createRef
와 useRef
를 보게 되는데, 차이가 뭘까요?
createRef
는 항상 새로운 ref를 생성합니다. 보통 클래스 컴포넌트에서 this.input = createRef()
형태로 많이 사용되며, 클래스 컴포넌트 인스턴스 필드에 저장됩니다.
함수형 컴포넌트에서는 위 옵션이 제공되지 않습니다. useRef
를 사용해 컴포넌트 렌더링마다 같은 ref를 반환받아 지속적(persistent)인 사용이 가능합니다.
실제 적용
TypeScript를 사용하는 NextJS App Router 환경에서의 검색창 컴포넌트 개발 상황입니다.
InputRef를 활용해 검색창 외부 영역의 X 버튼을 클릭해도 검색창 영역에 포커스가 남게 할겁니다.
결과물은 아래와 같습니다.
// 부모 컴포넌트는 간략하게 축약했습니다.
export default function FetchForm(){
const inputRef = useRef<HTMLInputElement>(null);
return (
...
<HistorySlots ref={ref}>
...
);
}
type HistorySlotsProps = {
setIsFocused: (value: boolean) => void;
setIsMouseEnter: (value: boolean) => void;
isWholeSearch: boolean;
};
// ref를 전달받는 자식 컴포넌트입니다.
const HistorySlots = forwardRef<HTMLInputElement, HistorySlotsProps>(
(
{ setIsFocused, setIsMouseEnter, isWholeSearch },
ref: ForwardedRef<HTMLInputElement>
) => {
...
<X
className='size-6 absolute right-3 top-1/2 transform -translate-y-1/2 active:bg-slate-400'
onClick={() => {
const buf = nameHistory.filter((_, i) => i !== index);
setNameHistory(buf);
removeSearchHistory(name);
if (ref && 'current' in ref && ref.current) {
ref.current.focus();
} else if (typeof ref === 'function') {
ref(null);
}
}}
...
);
export default HistorySlots;
참고한 글들
What's the difference between React's ForwardedRef and RefObject?
Property 'current' does not exist on type '((instance: HTMLDivElement | null) => void) | RefObject
'Frontend' 카테고리의 다른 글
[NextJS] 프론트엔드 서버는 필요한가? (CSR, SSR) (0) | 2024.10.27 |
---|---|
[tailwindCSS] CSS 컬러 설정이 특정 브라우저에서만 작동하는 경우 (2) | 2024.09.28 |
[NextJS] App router + Nginx 환경에서 React Suspense 문제 (2) | 2024.06.17 |
[JavaScript] export default와 export의 차이 (0) | 2024.02.17 |
[JavaScript] ESLint 설정하기 (airbnb-config) (2) | 2024.02.09 |