[React🌀] Ref 에 대한 고찰 🔍 / 1️⃣ - Ref 의 활용과 useRef
Ref는 언제 사용할까?
ref 는 references 의 약자로, React 에서 특정 컴퍼넌트를 접근하는 데에 사용하는 props 라고 이해하고 있으면 편합니다.
리액트에서 DOM을 컨트롤할때 주로 이 ref 를 이용하지만, ref 의 개념이 리액트를 처음 이용하는 사람들은 직관적으로 이해하기가 쉽지 않고, 리액트를 어느 정도 활용할줄 아는 사람도 정확히 ref가 뭔지 풀어 설명하기가 쉽지 않습니다.
때문에 이번 포스팅에서는, ref 에 대한 개념을 되새겨보고, 사용예시들을 바탕으로 이런저런 고민해볼 점들에 대해 다뤄보고자 합니다!
Ref는 render 메서드에서 생성된 DOM 노드나 React 엘리먼트에 접근하는 방법을 제공합니다.
그러나, 일반적인 데이터 플로우에서 벗어나 직접적으로 자식을 수정해야 하는 경우도 가끔씩 있습니다.
수정할 자식은 React 컴포넌트의 인스턴스일 수도 있고, DOM 엘리먼트일 수도 있습니다.
React는 두 경우 모두를 위한 해결책을 제공합니다.
리액트 공식문서에서는 위와 같은 문구로 ref 에 대한 소개를 하고 있습니다.
정리해보자면, ref 를 사용하는 케이스는 크게 두가지 이죠.
- 자식 컴퍼넌트를 직접 접근하여 수정할 때
- DOM 엘리먼트를 접근하고 싶을 때
사실 1번같은 경우는, 리액트 함수형 컴퍼넌트에서는 사용할 수 없는 방법이며, 리액트에서 지양하고 있는 방법이므로, 진짜 사용하는 용도는 2번 케이스가 대부분입니다. 특정 엘리먼트 DOM 에 접근해서, 해당 dom의 이벤트를 실행시키거나, 특정 attribute 들에 접근을 할 수 있죠. 일반적인 방법으로는 컨트롤하기 힘든 것들이요.
Ref의 바람직한 사용 사례는 다음과 같습니다.
- 포커스, 텍스트 선택영역, 혹은 미디어의 재생을 관리할 때.
- 애니메이션을 직접적으로 실행시킬 때.
- 서드 파티 DOM 라이브러리를 React와 같이 사용할 때.
리액트 공식문서에서는 위와 같은 케이스들을 구체적인 예시로 들고 있네요.
실제 사용하는 예시를 보시죠.
useOutsideClick
const useOutsideClick = ({ onClickOutside }) => {
const ref = useRef(null);
const handleClick = useCallback(
e => {
const inside = ref.current.contains(e.target);
if (inside) return;
onClickOutside();
},
[onClickOutside, ref]
);
useEffect(() => {
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [handleClick]);
return ref;
};
특정 엘리먼트의 바깥이 클릭됐을 때 원하는 함수가 실행되도록 하게 하는 훅입니다.
ref 를 이용해서, 클릭 이벤트 발생 시 , 해당 ref 를 가지고 있는 컴퍼넌트가 click 이벤트에 포함된 엘리먼트인지 확인하죠.
활용
const Use() => {
const ref = useOutsideClick({ onClickOutside : () => {
console.log("outside 가 클릭되었음!");
});
return (
<div>
<h2 ref={ref}>
inside
</h2>
</div>
);
}
위 코드와 같이 활용됩니다. 보시면 h2 엘리먼트에 ref라는 속성으로 ref를 넣어줍니다.
가장 기본적인 ref 지식들에 대해 알아봤습니다. 사실 여기까지만 알고 있어도, 실제 ref를 활용되는데는 큰 문제가 없죠. 하지만 이번 포스팅은 'ref에 대한 고찰' 아니겠습니까? 조금 더 깊게 들어가봅시다! 🔍
ref 에는 어떤 값이 들어가야하는가?
리액트 엘리먼트의 ref 속성에는 과연 무엇이 들어가야 할까요? React.createRef() 로 호출된 값? useRef() 로 호출된 값? 다른 값은 들어갈 수는 없을까요?
우리가 잘 모르고 넘어가는 것 중 하나는 'useRef와 ref 의 차이' 입니다. '컴퍼넌트 ref 에 넣어줄 값을 useRef로 선언한다.' 정도로만 알고 있거든요(제가 그랬습니다 하하 😅). 그러다보니, useRef 를 조금 다르게 활용한다던가, ref 값에 다른 값이 들어간다던가 하면 혼동이 오더라구요. 이번 기회로 조금 더 깊게 들어가보죠.
useRef란?
(이제 클래스형 컴퍼넌트는 잘 쓰이지 않고있기 때문에, React.createRef 는 생략하고, 함수형 컴퍼넌트가 사용하는 훅인 useRef 만 다루도록 하겠습니다.)
useRef 는 엘리먼트 ref용 값을 선언할 때 사용하는 훅, 이라고 생각하신다면, 사실 이 말은 절반은 맞고 절반은 틀렸습니다.
useRef 는 아래의 타입을 가지고 있는 하나의 객체를 선언해주는 함수입니다.
interface RefObject<T> {
readonly current: T | null;
}
useRef 의 결과 값은 항상 위와 같은 타입을 갖게 되죠. 테스트를 해볼까요?
리액트 컴퍼넌트 안에서 useRef 를 호출하고, 콘솔로 찍어보죠.
const testRef = useRef(null);
console.log({ testRef });
출력 결과
정말 특별한게 없어보이죠? 제가 말한대로만 생각해보면 굳이 왜 이 훅을 사용해야하는가? 라는 생각이 듭니다.
가장 중요한 점을 말하지 않았기 때문이죠. 그것은 바로 useRef로 인해 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지된다는 것입니다.
이 말이 한번에 잘 와닿지가 않죠? 좀 더 풀어서 설명하자면, useRef로 만들어진 객체는 React 가 만든 전역 저장소에 저장되기 떄문에, 함수를 재호출 하더라도 해당 컴퍼넌트의 생애주기동안은, 계속 current 값을 유지하고 있을 수 있다는 뜻입니다.
그리고 또 하나 중요한 점이 있는데, useRef로 부터 생성된 객체는 current 값이 변화해도 리렌더링에 관여하지 않는다는 점입니다. useState과의 가장 큰 차이점이죠. 이는 뒤 포스트에서 더 자세히 다루도록 할게요.
이것은 useRef()가 순수 자바스크립트 객체를 생성하기 때문입니다.
useRef() 와 {current: ...} 객체 자체를 생성하는 것의 유일한 차이점이라면
useRef는 매번 렌더링을 할 때 동일한 ref 객체를 제공한다는 것입니다.
리액트 공식문서에서도 위와 같이 설명하고 있습니다.
결과적으로 아래와 같은 케이스들에서 주로 useRef 를 이용한 변수들이 사용됩니다.
- setTimeout, setInterval 을 통해서 만들어진 id
- 외부 라이브러리를 사용하여 생성된 인스턴스
- scroll 위치
제경험상으로는 비동기적인 함수와 관련이 되어있다던가, 인터랙션 작업들에 관련이 된 변수들이 필요할 때, useRef를 필수적으로 이용하게 되는 것 같습니다. 예를들면, 스크롤에 따라, 특정 엘리먼트의 width 의 변화에 따라, 이벤트가 발생하게 해야될 때. 인터랙션이 복잡해질수록, 코드적으로도 많은 if 처리가 필요하겠죠? 이런 변화들이 reRendering 을 매번 일으킬 필요는 없을 것이고, 이럴 때 ref.current로 인터랙션의 동작들을 세세하게 구분시켜주곤 합니다.
ref 의 타입
interface RefObject<T> {
readonly current: T | null;
}
type RefCallback<T> = { bivarianceHack(instance: T | null): void }["bivarianceHack"];
type Ref<T> = RefCallback<T> | RefObject<T> | null;
type LegacyRef<T> = string | Ref<T>;
실제 @types/react 에 담겨있는, ref props 의 타입들입니다.
맨 아랫줄부터 읽어보죠.
직접적으로 ref 속성에 들어가는 타입은 LegacyRef<T> 의 타입입니다.
LegacyRef<T> 는 string 값, 혹은 Ref<T> 가 들어갈 수 있는군요?
헌데 Ref<T> 는 callback 형태인 RefCallback<T>, obejct 형태인 RefObject<T>, 그리고 null 이 가능하네요.
(타입을 타고타고 들어가서 읽기가 복잡하긴 하네요;)
string 이 들어가는 것은 Legacy이므로 제외하고, null도 제외한다면
실제로 ref 에 들어가는 형태는 크게 RefCallback 과 RefObject 두 가지입니다.
useRef 의 타입도 한번 살펴볼까요?
일반적으로 useRef 훅으로 호출되는 값의 타입은 다음과 같습니다.
function useRef<T = undefined>(): MutableRefObject<T | undefined>;
interface MutableRefObject<T> {
current: T;
}
그렇습니다. 위에서 말했던 것과 같이, useRef는 결국 컴퍼넌트 생태주기를 함께하는 {current: T} 형태의 객체를 선언하는 것에 불과하다는 것이죠!
그렇다면 우리가 일반적으로 사용하던 ref 를 통해 엘리먼트 노드에 접근하는 것은 어떻게 가능한 것일까요?
React는 노드가 변경될 때마다 변경된 DOM 노드에 그것의 .current 프로퍼티를 설정할 것입니다.
네 그렇습니다. 바로 리액트에서 이미 컴퍼넌트의 ref props 로 들어온 객체의 current 프로퍼티를 설정하도록 미리 구현이 되어있기 때문입니다. 즉, ref 에 useRef를 통해 생성된 객체를 집어 넣어주면, 해당 컴퍼넌트가 변경될 때마다 객체의 current 프로퍼티가 컴퍼넌트의 DOM 객체로 설정이 되고, 우리는 그 DOM 객체를 이용할 수 있게 되는 것이죠!
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// `current` points to the mounted text input element
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
이런식으로 사용이 가능합니다.
그렇다면 여기서 문제!
const inputEl = { current: null };
function App() {
const onButtonClick = () => {
inputEl.current.focus();
inputEl.current.click();
};
const onInputClick = () => {
alert("input clicked");
};
console.log({ inputEl });
return (
<>
<input onClick={onInputClick} ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
useRef 대신 그냥 global 변수로 inputEl 를 선언한 케이스인데요,
과연, 버튼이 클릭됐을 때 inputEl.current.focus(); 과 inputEl.current.click(); 이 실행될까요?
그리고 console.log({ inputEl }); 의 결과는 어떻게 될까요?!
정답을 잘 모르겠다면, 위 글을 다시 천천히 읽어보시고 직접 테스트해보십쇼!!
RefCallback<T> 형태는 무엇일까??
useRef 포함해서 지금껏 얘기했던 ref 들은 전부 RefObject<T> 형태의 ref 였죠. 그렇다면, RefCallback<T> 형태의 ref는 대체 무엇일까요?
바로 callbackRef 입니다. ref에는 콜백 형태로도 값을 집어넣을 수 있습니다. 이 콜백 ref를 이용해서 ref가 설정되고, 해제되는 상황의 동작들을 세세하게 다룰 수 있어요. 사실 말만들으면 이해가 잘 안됩니다. 바로 예시로 보시죠.
import React, { useState, useCallback } from "react";
export default function App() {
const [height, setHeight] = useState(0);
const callbackRef = (element) => {
if (element) {
setHeight(element.getBoundingClientRect().height);
}
};
return (
<div>
<input ref={callbackRef}>
</div>
);
}
해당 엘리먼트가 변경되었을 때, 그 엘리먼트의 값에 dependant 하게끔 함수를 실행시켜주고 싶다면? 바로 이 callbackRef를 이용하면 됩니다. 위 예시에서는, input 엘리먼트에 callbackRef를 집어넣어줘서, input 엘리먼트에 height값을 바로 height 이라는 변수에 넣어주고 있습니다. 잘 활용하면 코드량도 줄고 생각보다 유용할 때가 많습니다!
https://ko.reactjs.org/docs/refs-and-the-dom.html#callback-refs
최종 복습
자, 여기까지 나온 내용들을 모두 이해하셨는지 모르겠습니다. 사실 이 정도로 생각해보는 것도 쉽지 않은 것 같아요. 저도 레퍼런스도 찾아보고, 직접 코드로 테스트도 해보니 시간이 생각보다 엄청 오래 걸렸네요.
여태까지 나왔던 내용들을 전부 파악했으면 이해할 수 있는 좋은 예시를 가져와봤습니다.
const TestLi = ({ active }) => {
const listRef = useRef<HTMLLIElement>(null)
useEffect(() => {
if (active && listRef?.current) {
listRef?.current?.scrollIntoView({ inline: 'center'})
}
}, [active, listRef?.current])
return (
<li className={cx({ active })} ref={listRef}/>
)
}
이 코드는 아래와 같이 변환할 수 있습니다.
const TestLi = ({ active }) => {
const [listRef, setRef] = useState<HTMLLIElement>(null)
useEffect(() => {
if (active && listRef) {
listRef.scrollIntoView({ inline: 'center'})
}
}, [active, listRef])
return (
<li className={cx({ active })} ref={setRef}/>
)
}
callbackRef 를 이용해서, useState로 선언된 setRef를 넘겨줍니다. 그러면 li 엘리먼트가 변화할때마다, listRef state 값에 해당 엘리먼트 DOM 객체가 들어가게 되겠죠? 이런식으로 수정이 가능합니다.
그러면 useRef로 하면 될 것이지 왜 굳이 이렇게 코드를 짜냐구요? useRef 의 의도를 다시한번 생각해보십쇼!!!
if you put [ref.current] in dependencies, you're likely making a mistake. Refs are for values whose changes don't need to trigger a re-render.
위 예시와 관련된 stackoverflow 질문입니다. 읽어보시면, 이해하는 데에 도움이 되실 겁니다!
ref에 대한 고찰 1부는 여기까지입니다.
2부에서는, useRef & useState & global variable 각각의 차이, 언제 어떤 것을 쓰는 것이 좋은지에 대해 좀 더 구체적인 예시와 함께 정리해볼 예정입니다. (2부는 1부보다 좀 더 짧을 것 같아요)
긴 글 읽어주셔서 감사합니다!
2부가 나왔습니다.
https://programming119.tistory.com/266
참조
https://ko.reactjs.org/docs/refs-and-the-dom.html#callback-refs
https://blog.logrocket.com/complete-guide-react-refs/
https://hyeok999.github.io/2020/01/07/react-velo-10/
https://velog.io/@vagabondms/%ED%95%9C-%EC%A3%BC%EC%97%90-%ED%95%9C-%EC%A3%BC%EC%A0%9C-callback-Ref