안녕하세요, 단테입니다.
오늘은 친숙한 훅 api인 useRef에 대해 알아보겠습니다.
useRef는 각 렌더링에서 참조하는 값을 동일한 값으로 유지합니다.
useRef는 훅을 처음 대하는 개발자에게 자칫 혼란을 가져다주는 api 입니다. useState와는 어떤 점이 다르고, 정확히 어떤 부분에서 사용해야 하는지, 그리고 사용해도 되기는 하는지, 타입스크립트와는 어떻게 사용해야 하는지 등등 고민되는 포인트가 많기 때문입니다.
useRef의 특징은 무엇일까요?
클래스 컴포넌트와 함수형 컴포넌트의 차이점에 대해 알고 계신가요?
클래스 컴포넌트 내부에 선언된 인스턴스들은 리렌더링 시 값이 변경되지 않습니다.
이와 반면에 함수형 컴포넌트에서 선언된 변수들은 렌더링 될 때마다 다시 생성됩니다. 해당 변수가 함수를 참조하고 있어도 동일합니다.
아래 코드의 MockClass는 새로운 인스턴스 생성시마다 랜덤 값을 생성합니다.
useEffect
내부에서는 setInterval 함수를 통해 1초마다 count
의 값을 1씩 증가시키고 있네요.
useEffect 이전 줄에서 randomRef.current를 콘솔에 출력하고 있는데요, 1초마다 한번씩 이 콘솔 값이 출력됩니다.
randomRef.current가 처음 초기화 되었을 때의 랜덤 값이 300이라면,
매초마다 동일한 300이 콘솔에 찍힐 것입니다.
class MockClass {
randomValue;
constructor() {
this.randomValue = Math.random() * 1000;
console.log("MockClass has built: ", this.randomValue);
}
get random() {
return this.randomValue;
}
}
function Component() {
const [count, setCount] = useState(0);
const randomRef = useRef(new MockClass().random);
const intervalRef = useRef();
console.log("randomRef.current: ", randomRef.current);
useEffect(() => {
if (intervalRef.current) {
return () => {
clearInterval(intervalRef.current);
};
} else {
intervalRef.current = setInterval(() => {
setCount((prev) => prev++);
}, 1000);
}
}, []);
return <Fragment></Fragment>;
}
function App() {
return <Component />;
}
아니, 근데 useRef를 선언할 때 클래스 인스턴스를 생성해서 초기화 시켰잖아요, 그러면 당연히 안변하는 거 아닌가요?
아니요, useRef에 인자로 전달된 new MockClass()
는 컴포넌트 렌더링이 될 떄마다 계속 호출됩니다.
콘솔 출력 값을 보면 randomRef.current는 항상 동일한 값을 가지고 있지만 MockClass의 constructor는 렌더링시마다 호출되는 것을 알 수 있습니다.
useRef()의 current 값이 변경되지 않는다는 것만
느낌적
으로 알기 때문에 인자에서 호출되는 함수또한 다시는 호출되지 않는 다고착각
할 수 있는데요, 이 부분을 유의해서 비싼 연산은 이뤄지지 않도록 해야겠습니다.
function Image(props) {
// ⚠️ IntersectionObserver는 모든 렌더링에서 생성됩니다
const ref = useRef(new IntersectionObserver(onIntersect));
// ...
}
function Image(props) {
const ref = useRef(null);
// ✅ IntersectionObserver는 한 번 느리게 생성됩니다
function getObserver() {
if (ref.current === null) {
ref.current = new IntersectionObserver(onIntersect);
}
return ref.current;
}
// 필요할 때 getObserver()를 호출해주세요
// ...
}
예제 코드의 intervalRef.current 또한 명시적으로 변경하지 않는다면 컴포넌트가 리렌더링 되더라도 변경되지 않기 때문에 다음 처럼 특정 이벤트 핸들러에서 clearInterval
를 호출하는 것도 가능하겠네요.
// ...
function handleCancelClick() {
clearInterval(intervalRef.current);
}
// ...
리엑트의 여러 리렌더링 원인에 useRef().current의 변경은 포함되지 않습니다. 즉, useRef 값이 변경될 때 ref는 리엑트에게 해당 값의 변경을 알려주지 않습니다.
만약 useRef 값의 변경에 따라 특정 로직을 수행하고 싶다면, ref 대신 상태값을 사용하거나, useEffect의 dependency array 내부에서 해당 ref를 참조할 수 있게 해야 합니다.
useEffect(() => {
barelyNothingHandler();
},[rareRef.current])
리엑트에서 vanilla js, jquery와 같이 명시적으로 돔을 조작하는 경우는 많지 않습니다.
리엑트는 명령형(imperative) 프로그래밍이 아닌 선언형 (declarative) 프로그래밍을 지향하기 때문입니다.
따라서 상태 값의 변경에 따라 UI를 변경하게 하는 것이 일반적입니다.
하지만 ref가 필요한 순간이 있는데요,
컴포넌트 렌더링 시 자동으로 인풋 창에 포커싱이 가게 하는 경우가 해당 예시입니다.
function App() {
const inputElement = useRef();
const focusInput = () => {
inputElement.current.focus();
};
return (
<>
<input type="text" ref={inputElement} />
<button onClick={focusInput}>Focus Input</button>
</>
);
}
ref에 대한 공식 문서의 설명은 다음 링크에서 찾아볼 수 있어요.
https://ko.reactjs.org/docs/refs-and-the-dom.html#dont-overuse-refs
위의 예시 말고도 아래의 경우 ref가 필요할 수 있습니다.
클래스 컴포넌트와 함수형 컴포넌트의 ref 사용방법은 상이한 부분이 있습니다.
클래스 컴포넌트는 ref를 attribute로 받을 수 있습니다. 하지만 함수형 컴포넌트는 ref를 props로 받을 수 없는데요, 그 이유는 클래스 컴포넌트와 다르게 함수형 컴포넌트는 인스턴스가 없기 때문입니다.
CustomTextInput은 클래스 컴포넌트로 작성되었기 때문에 다음 처럼 ref props에 textInput을 넣을 수 있습니다. 다만 함수형 컴포넌트라면 다른 접근 방법을 취해야 합니다.
class AutoFocusTextInput extends React.Component {
constructor(props) {
super(props);
this.textInput = React.createRef();
}
componentDidMount() {
this.textInput.current.focusTextInput();
}
render() {
return (
<CustomTextInput ref={this.textInput} />
);
}
}
함수형 컴포넌트에 ref를 넘기기 위해서는 forwardRef
를 사용해야 합니다.
하지만, 함수형 컴포넌트 내부에서 ref 사용이 아예 되지 않는다는 것은 아닙니다.
다음처럼 컴포넌트 내부에 있는 HTMLElement
에는 ref를 사용할 수 있습니다.
function CustomTextInput(props) {
// textInput은 ref 어트리뷰트를 통해 전달되기 위해서
// 이곳에서 정의되어야만 합니다.
const textInput = useRef(null);
function handleClick() {
textInput.current.focus();
}
return (
<div>
<input
type="text"
ref={textInput} />
<input
type="button"
value="Focus the text input"
onClick={handleClick}
/>
</div>
);
}
리엑트 공식문서에는 Ref를 남용하지 말라고 안내하고 있습니다.
그러면 useRef 사용을 되도록 하지 말고 안티패턴이라고 생각하고 몰라도 될까요?
아뇨, 알아야 합니다. 상태값과 무엇이 다른지를 알아야 리엑트를 더욱 잘 이해할 수 있습니다. useRef가 왜 클래스 컴포넌트의 인스턴스 와 유사하다고 하는지 알아야 클래스 컴포넌트와 훅의 차이점에 대해 더욱 잘 이해할 수 있습니다.
그 뿐만이 아닙니다. 리엑트 훅이 처음 생겨난 이래로, api 사용 불편에 대한 개선사항 및 특정 문제를 직면했을 때의 우회 방법들에는 useRef를 이용한 방법이 많습니다.
useRef를 사용하는 대표적인 사례가 useCallback, 이벤트 핸들러 관련 이슈이죠. 관련 불편 사항을 해결하기 위해 useEvent가 RFC에 제안되었습니다.
useEvent 10분만에 이해시켜주는 영상
https://www.youtube.com/watch?v=lPlg3zUPYMA
import React, { useRef } from "react";
const Counter = () => {
const count = useRef(0);
count.current++;
return <div>count:{count.current}</div>;
};
export default Counter;
react는 state/props 변경에 따른 리렌더링부터 ui painting 이르기까지 reconciliation
이라는 과정을 거칩니다.
reconciliation은
render phase
commit phase 로 나뉘어 집니다.
render phase는 리렌더링에 따른 ui 변경 내역을 virtual dom에 기록하는 단계이며
commit phase는 실제 ui painting으로 이어지는 단계입니다.
클래스 컴포넌트에서는 render phase 와 commit phase는 일대일 관계를 띄지만, 함수형 컴포넌트에서는 일대일 관계를 보장하지 않아, render 함수 내부에서 ui 변경과 연관있는 값을 변경한다면, 예상치 못하게 동작할 수 있습니다.
따라서 다음과 같이 commit phase에 동작하는 useEffect 내부에서 값을 수정해주는 것이 정상적인 처리 방법입니다.
const Counter = () => {
const count = useRef(0);
let currentCount = count.current;
useEffect(() => {
count.current = currentCount;
});
currentCount += 1;
return <div>count:{currentCount}</div>;
};
오늘은 useRef의 사용 사례 및 사용 방법에 대해 알아보았습니다.
커스텀 훅을 작성하거나 훅 내부에서 원하는 로직을 구성하는데 많이 사용되는 훅 api이기 때문에 해당 api의 동작 방식을 알고 있는 것은 필수적인 요소입니다.
공식 문서를 꼭 읽어보시길 바라며 하단에 참고하기 좋은 문서 링크를 남겨놓습니다.
감사합니다.
참고하기 좋은 문서
도큐먼트 어쩌구로 도배를 해놨더니 어느순간 사이트가 먹통이 되어버렸씁니다...
잘 읽었습니다