React useRef 훅와 필요에 따라 설정해 사용하는 custom Hook에 대해 정리해보았습니다.
1️⃣ useRef
리액트 어플리케이션에서 <input /> 요소에 포커스를 설정하거나 특정 컴포넌트의 위치로 스크롤 하는 등 DOM을 직접 선택해야하는 경우 사용합니다.
● useRef의 타입 정의
타입 정의를 보면 useRef는 MutableRefObejct 또는 RefObejct를 반환합니다.
// useRef의 타입 정의 3가지
function useRef<T>(initialValue: T): MutableRefObject<T>; // 👈 일반적인 값을 저장할 때
function useRef<T>(initialValue: T|null): RefObject<T>; // 👈 DOM 요소를 참조할 때
function useRef<T = undefined>(): MutableRefObject<T|undefined>; // 👈 초기값 없이 사용할 때
○ MutableRefObejct
current의 값을 변경할 수 있습니다. 만일 useRef의 제네릭에 HTMLInputElement | null 타입을 넣어주었다면, 해당 useRef는 첫번째 타입 정의를 따를 것이고, 이 경우, MutableObject의 current는 변경할 수 있는 값이 되어 ref.current의 값이 바뀌는 사이드 이펙트가 발생할 수 있습니다.
○ RefObject
current의 값을 변경할 수 없습니다. (readonly) useRef의 제네릭으로 HTMLInputElement를 넣고, 초기 인자에 null을 넣을 경우 useRef의 두번째 타입 정의를 따르게 되는데, 이때는 RefObejct를 반환하여 ref.current의 값을 임의로 변경할 수 없게 됩니다.
○ MutableRefObject vs RefObject 차이점
// MutableRefObject - current 값을 변경할 수 있음
const countRef = useRef<number>(0);
countRef.current = 1; // ✅ 가능: current 값 변경 가능
// RefObject - current 값이 readonly
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current = someElement; // ❌ 오류: current는 readonly
2️⃣ forwardRef : 자식 요소에 ref 전달하기
리액트 컴포넌트에서 ref를 prop으로 전달하기 위해서는 forwardRef를 사용해야합니다.
※ ref 라는 속성의 이름은 리액트에서 DOM 요소에 접근 이라는 특수한 목적을 사용되므로, props를 넘겨주는 방식으로는 자식요소에 전달될 수 없습니다. ref라는 이름 말고 inputRef 등의 이름을 사용하면 forwardRef를 사용하지 않아도 됩니다.
● forwardRef의 타입정의
// forwardRef의 타입 정의
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, P> // 👈 콜백 함수 (컴포넌트)
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
● ForwardRefRenderFunction의 타입 정의
forwardRef에 인자로 넘겨주는 콜백함수입니다.
// ForwardRefRenderFunction 의 타입 정의
interface ForwardRefRenderFunction<T, P = {}> {
(props: P, ref: ForwardedRef<T>): ReactElement | null; // 👈 props와 ref를 매개변수로 받음
displayName?: string;
defaultProps?: never;
propTypes?: never;
}
ForwardRefRenderFunction는 2개의 타입 매개변수 T와 P를 받고, P는 일반적인 리액트 컴포넌트에서 자식 컴포넌트로 넘겨주는 props 타입을, T는 ref로 전달하려는 요소의 타입을 나타냅니다. 이 부분에서 주목할 점은 ref의 타입이 T를 래핑한 형태인 ForwardRef<T>라는 것입니다.
// ForwardedRef<T>의 타입 정의
type ForwardedRef<T> =
| ((instance: T | null) => void) // 👈 콜백 함수
| MutableRefObject<T | null> // 👈 useRef로 생성된 객체
| null; // 👈 ref가 없을 경우
useRef의 반환타입이 MutableRefObejct<T> 또는 RefObject<T>인 반면에, ForwardedRef에는 오직 MutableRefObject만 들어올 수 있습니다. MutableRefObejct가 RefObject보다 더 넓은 범위 타입을 가지므로, 부모 컴포넌트에서 ref를 어떻게 선언했는지와 관계없이 자식 컴포넌트가 해당 ref를 수용할 수 있습니다.
● useImperativeHandle
ForwardRefRenderFunction과 함께 사용할 수 있는 훅으로, 부모 컴포넌트에서 ref를 통해 자식 컴포넌트에서 정의한 커스터마이징된 메서드를 호출할 수 있습니다.
○ 자식 컴포넌트에서 설정하는 코드 예시
// 자식 컴포넌트에서 노출할 메서드 타입 정의
interface CreateFormHandle {
submit: () => void;
reset: () => void;
}
// 자식 컴포넌트
const CreateForm = forwardRef<CreateFormHandle, CreateFormProps>((props, ref) => {
// 👆 제네릭: <노출할 메서드 타입, props 타입>
const { onSubmit } = props;
const [formData, setFormData] = useState({
name: '',
email: '',
});
// 폼 제출 처리 함수
const handleSubmit = useCallback(() => {
onSubmit(formData);
}, [formData, onSubmit]);
// 폼 초기화 함수
const handleReset = useCallback(() => {
setFormData({ name: '', email: '' });
}, []);
// ref를 통해 부모에게 노출할 메서드 정의
useImperativeHandle(ref, () => ({
submit: handleSubmit, // 👈 외부에서 호출할 메서드
reset: handleReset, // 👈 외부에서 호출할 메서드
}));
return (
<form>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="이름"
/>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="이메일"
/>
</form>
);
});
○ 부모 컴포넌트에서 사용하는 코드 예시
// 부모 컴포넌트에서 사용
const ParentComponent = () => {
const formRef = useRef<CreateFormHandle>(null); // 👈 자식 컴포넌트의 메서드 타입을 지정
const handleSaveClick = () => {
// 자식 컴포넌트의 submit 메서드 호출
formRef.current?.submit(); // 👈 옵셔널 체이닝으로 안전하게 호출
};
const handleResetClick = () => {
// 자식 컴포넌트의 reset 메서드 호출
formRef.current?.reset(); // 👈 옵셔널 체이닝으로 안전하게 호출
};
return (
<div>
<CreateForm ref={formRef} onSubmit={(data) => console.log(data)} />
<button onClick={handleSaveClick}>저장</button>
<button onClick={handleResetClick}>초기화</button>
</div>
);
};
이 예시처럼 자식 컴포넌트에서는 ref와 정의된 CreateFormHandle을 통해 부모 컴포넌트에서 호출할 수 있는 함수를 생성하고, 부모 컴포넌트에서는 다음처럼 current.submit()를 사용하여 자식 컴포넌트의 특정 메서드를 실행할 수 있게 됩니다.
3️⃣ useRef의 여러가지 특성
useRef는 자식 컴포넌트를 저장하는 변수로 사용하는 것 뿐 아니라 아래와 같은 방식으로도 사용할 수 있습니다.
- useRef로 관리되는 변수는 값이 바뀌어도 컴포넌트 리렌더링이 발생하지 않는데, 이 특성을 이용해 상태가 변경되도 불필요한 리렌더링을 피할 수 있습니다.
- 리액트 컴포넌트 상태는 상태 변경 함수를 호출하고 렌더링된 이후에 업데이트된 상태를 조회할 수 있습니다. 반면 useRef로 관리되는 변수는 값 설정 후 즉시 조회할 수 있습니다.
● 예시 코드
아래 코드는 자동 재생 기능이 있는 이미지 슬라이더(carousel) 컴포넌트 입니다. 여기서 isAutoPlayPause는 현재 자동 재생이 일시 정지되었는지 확인하는 ref 입니다. 이 변수는 렌더링에 영향을 미치지 않으며, 값이 변경되더라도 다시 렌더링을 기다릴 필요 없이 사용할 수 있어야 합니다.
isAutoPlayPause.current에 null이 아닌 값을 할당해서 마치 변수처럼 활용할 수도 있습니다.
import React, { useRef, useState, useEffect } from 'react';
const AutoPlaySlider = () => {
const [currentSlide, setCurrentSlide] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
// 👇 리렌더링을 발생시키지 않는 값을 저장하는 ref
const isAutoPlayPause = useRef<boolean>(false);
// 👇 타이머 ID를 저장하는 ref - 이것도 UI에 직접 영향을 주지 않는 값
const timerRef = useRef<NodeJS.Timeout | null>(null);
// 슬라이드를 다음으로 넘기는 함수
const nextSlide = () => {
setCurrentSlide((prev) => (prev + 1) % 5); // 총 5장의 슬라이드가 있다고 가정
};
// 자동 재생 시작
const startAutoPlay = () => {
// 👇 기존 타이머가 있으면 제거 (ref.current 직접 접근)
if (timerRef.current) clearInterval(timerRef.current);
// 👇 새 타이머 ID를 ref에 저장 (UI와 무관한 값)
timerRef.current = setInterval(() => {
// 👇 ref 값을 조건으로 사용 (상태 업데이트 없이 즉시 최신 값 참조)
if (!isAutoPlayPause.current) {
nextSlide();
}
}, 3000);
};
// 마우스 오버 시 자동 재생 일시 정지
const handleMouseEnter = () => {
// 👇 setState 없이 값을 즉시 변경 (리렌더링 발생 안 함)
isAutoPlayPause.current = true;
// 👇 변경 후 즉시 새 값에 접근 가능 (setState는 비동기적으로 업데이트됨)
console.log('자동 재생 일시 정지:', isAutoPlayPause.current); // true 바로 출력
};
// 마우스 아웃 시 자동 재생 재개
const handleMouseLeave = () => {
// 👇 다시 값을 즉시 변경 (리렌더링 없음)
isAutoPlayPause.current = false;
console.log('자동 재생 재개:', isAutoPlayPause.current); // false 바로 출력
};
// 재생/일시정지 토글
const togglePlay = () => {
// 👇 이것은 상태 변경 - 리렌더링 발생
setIsPlaying((prev) => !prev);
};
// isPlaying 상태가 변경될 때 자동 재생 시작 또는 중지
useEffect(() => {
if (isPlaying) {
startAutoPlay();
} else {
// 👇 ref에 저장된 타이머 ID에 접근해서 정리
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
// 컴포넌트 언마운트 시 타이머 정리
return () => {
// 👇 cleanup 함수에서도 ref의 최신 값에 접근 가능
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [isPlaying]); // 👈 isPlaying이 변경될 때만 실행
return (
<div
className="slider-container"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="slides">
{/* 슬라이드 내용 */}
<div className="slide" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
{[0, 1, 2, 3, 4].map((index) => (
<div key={index} className="slide-item">
슬라이드 {index + 1}
</div>
))}
</div>
</div>
<button onClick={togglePlay}>
{isPlaying ? '일시정지' : '재생'} {/* 👈 isPlaying은 상태로 UI에 표시 */}
</button>
<div className="indicators">
{[0, 1, 2, 3, 4].map((index) => (
<button
key={index}
className={currentSlide === index ? 'active' : ''}
onClick={() => setCurrentSlide(index)}
/>
))}
</div>
</div>
);
};
export default AutoPlaySlider;
4️⃣ 리액트 훅의 규칙
리액트 훅은 호출 순서에 의존하기 때문에, 리액트 훅을 안전하기 사용하기 위해서는 아래 2가지 규칙을 지켜야 합니다.
리액트의 모든 컴포넌트 렌더링에서 훅의 순서가 항상 동일하게 유지되어야 하며, 이를 통해 항상 동일한 컴포넌트 렌더링이 보장 됩니다. 이 규칙들을 준수함으로써 컴포넌트의 모든 상태 관련 로직을 좀 더 명확히 할 수 있습니다.
1. 훅은 항상 최상위 레벨에서 호출되어야 합니다.
조건문, 반복문, 중첩함수, 클래스 등의 내부에서는 훅을 호출하지 않아야 하며, 반환문으로 함수 컴포넌트가 종료되거나 조건문 또는 변수에 따라 반복문 등으로 훅의 호출여부가 결정되어서는 안됩니다. 이를 유의해야 useState나 useEffect가 여러번 호출되더라도 훅의 상태를 올바르게 유지할 수 있게 됩니다.
2. 훅은 항상 함수 컴포넌트나 커스텀 훅 등의 리액트 컴포넌트 내에서 호출되어야 합니다.
5️⃣ 커스텀 훅
리액트에서는 사용자 정의 훅 생성도 가능합니다. 커스텀 훅은 리액트 컴포넌트 내에서만 사용할 수 있으며, 이름은 반드시 use로 시작되어야 합니다.
● useInput
가장 일반적인 커스텀 훅인 useInput을 살펴보겠습니다. useInput은 인자로 받은 초깃값을 useState로 관리하며, 해당 값을 수정할 수 있는 onChange함수를 input 값과 함께 반환하는 훅입니다.
useInput 예시 코드
// useInput 예시 코드
import { useState, useCallback } from 'react';
function useInput(initialValue) {
const [value, setValue] = useState(initialValue);
const onChange = useCallback((e) => {
setValue(e.target.value);
}, []);
const reset = useCallback(() => {
setValue(initialValue);
}, [initialValue]);
return { value, onChange, reset };
}
export default useInput;
useInput 사용 예시 코드
아래 코드에서 {...name} 과 같이 스프레드 연산자로 name의 모든 속성을 전달하고 있지만, HTML input 요소가 인식하는 속성들만 실제로 적용되고 그 외 속성(reset)은 무시됩니다.
import React from 'react';
import useInput from './useInput';
function SignupForm() {
const name = useInput('');
const email = useInput('');
const password = useInput('');
const handleSubmit = (e) => {
e.preventDefault();
console.log({
name: name.value,
email: email.value,
password: password.value
});
// 폼 초기화
name.reset();
email.reset();
password.reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>이름</label>
<input type="text" {...name} placeholder="이름을 입력하세요" />
</div>
<div>
<label>이메일</label>
<input type="email" {...email} placeholder="이메일을 입력하세요" />
</div>
<div>
<label>비밀번호</label>
<input type="password" {...password} placeholder="비밀번호를 입력하세요" />
</div>
<button type="submit">가입하기</button>
</form>
);
}
export default SignupForm;
● 타입스크립트로 커스텀 훅 강화하기
useInput 예시 코드 파일의 확장자를 .ts로 변환하면 아래와 같은 컴파일 에러가 뜹니다.
// 기존 코드에 대해 .ts 변환 시 뜨는 오류 코드
function useInput(initialValue) {
^^^^^^^^^^^^ Parameter 'initialValue' implicitly has an 'any' type.
const onChange = useCallback((e) => {
^^^ Parameter 'e' implicitly has an 'any' type.
컴파일 오류의 원인은 useInput 함수의 인자로 넣어준 initialValue와 onChange 함수의 인자로 넣어준 e의 타입이 지정되지 않았기 때문에 발생하는 에러로 두 군데 타입을 명시적으로 정의하면 해결 됩니다.
// 해결한 코드
import { useState, useCallback, ChangeEvent } from 'react';
function useInput<T>(initialValue: T) {
const [value, setValue] = useState<T>(initialValue);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value as unknown as T);
}, []);
const reset = useCallback(() => {
setValue(initialValue);
}, [initialValue]);
return { value, onChange, reset };
}
export default useInput;