ref가 변경되어도 컴포넌트가 새로 렌더링되지는 않으므로, useEffect
는 ref의 변경에 대해 즉각 감지를 할 수 없습니다. 다른 값 변경 등의 이유로 컴포넌트가 렌더링이 새로 됐을 때 비로소 변경된 ref 값을 볼 수 있게 됩니다.
따라서 다음 조건에 따라 각 ref의 갱신을 관리하는 것이 좋습니다.
useEffect
가 아니라면, 사용할 ref는 useCallback
+ callback ref
로 작성하기useEffect
라면, useRef
의 값을 의존성으로 담아도 됨리액트 프레임워크로 개발을 하다보면, DOM 노드의 사이즈(width
, height
)나 포지션(top
, bottom
, ...)값을 구해야하는 작업이 필요할 때가 있죠.
저도 종종 이런 상황을 겪는데요, 그 중 하나로 DOM 노드에 ref를 등록해서 height 값을 업데이트 시켜주는 커스텀 훅을 작성했었습니다. 코드는 다음과 같습니다.
// ref object에 DOM 노드가 설정되면 height 값을 설정해주는 커스텀 훅
const useGettingHeight = () => {
const [height, setHeight] = useState(null);
const ref = useRef<HTMLElement>();
// ref에 DOM 노드가 설정되는 것을 감지하여 height 설정(?)
useEffect(() => {
if (!!ref.current) setHeight(ref.current.getBoundingClientRect().height);
}, [ref.current]);
return [height, ref];
};
function App() {
const [height, ref] = useGettingHeight();
return (
<div ref={ref} style={{ height: `${height}px` }}>
이렇게 작성해도 될까요?
</div>
);
}
위 코드는 정상적으로 <div>
에 인라인 스타일 height: 18px
값이 설정되기는 합니다. 언뜻 보기에는 문제가 없는 코드같아요. 그런데 과연 진짜 문제가 없는 코드일까요?🤔
위 예시에서 우리는 useEffect
의 두 번째 인자로 ref.current
를 추가함에 따라, useEffect
가 ref 변경을 감지하여 setHeight
이 실행된다고 이해하기 쉬운데요.
아쉽게도 이는 잘못된 이해입니다.
왜냐하면 ref
는 컴포넌트가 다시 렌더링될 때까지 값이 업데이트 되었는지 확인할 수 없거든요. 또한 ref
의 값이 갱신되어도 컴포넌트는 새로 렌더링이 되지 않습니다. 다른 이유로 인해 컴포넌트가 다시 렌더링되어서야 갱신된 값을 확인할 수 있어요. 이 말은 곧, 렌더링을 건너뛰는 useEffect는 다음 렌더링 전에는 ref
에 대한 어떤 변경도 볼 수 없다는 것을 뜻합니다.
무슨 말인지 좀 어려우신가요? 아래 예시를 보면 좀 더 이해가 수월하실 거에요.
const useGettingHeight = () => {
const [height, setHeight] = useState(null);
const ref = useRef<HTMLElement>();
useEffect(() => {
if (!!ref.current) setHeight(ref.current.getBoundingClientRect().height);
}, [ref.current]);
return [height, ref];
};
const useLoading = () => {
const [loading, setLoading] = useState(true);
// 컴포넌트 마운트 시 loading false로 업데이트
useEffect(() => setLoading(false), []);
return loading;
};
function App() {
// 첫 렌더링 시 ref가 attached됨
const [height, ref] = useGettingHeight();
// loading 값이 true가 된 이후 ref가 attached됨
const [lazyHeight, lazyRef] = useGettingHeight();
const loading = useLoading();
return (
<>
<div ref={ref}>ref height: {height.toString()}</div>
{!loading && <div ref={lazyRef}>Lazy ref height: {lazyHeight.toString()}</div>}
</>
);
}
위에서 작성했던 DOM 노드의 height을 가져오는 훅을 사용했습니다. 눈 여겨볼 점은 useLoading
가 추가된 것인데요, 컴포넌트 마운트 이후에 loading
값이 true
가 되면서 추가로 두 번째 <div>
가 렌더링되고 ref에 DOM node 참조값이 담깁니다. 첫 번째 ref와의 구분을 위해 lazyRef
라고 명명했습니다.
이 코드를 실행하면 어떻게 될까요? 둘 다 height을 정상적으로 출력할까요?
링크를 클릭해서 직접 확인해보세요 Link
컴포넌트 마운트 이후에 설정되었던 lazyRef
의 height은 값이 설정되지 않았습니다. 결과가 이해가 되지 않는다면 다음의 실행 순서를 천천히 따라가봅시다.
<div>
가 렌더링되고, ref
에 DOM node 참조값이 담깁니다.lazyRef
는 아직 두 번째 <div>
가 렌더링되지 않았으므로 값이 없습니다.useGettingHeight
의 ref.current
를 의존성으로 가지는 useEffect
콜백이 실행됩니다.ref.current
의존성 때문에 호출되는 것이 아닙니다! 모든 useEffect는 의존성과 관계없이 컴포넌트 첫 렌더링 이후 한 번 실행됩니다.useLoading
의 useEffect
콜백도 실행됩니다.ref.current
는 DOM node 참조값을 가지고 있으므로 setHeight
을 호출하여 height
state를 업데이트해줍니다.lazyRef.current
는 값이 없으므로 setHeight
이 호출되지 않습니다.loading
값이 true
가 됩니다.height
값이 존재하므로 첫 번째 <div>
의 innerText에 18이라는 값이 찍힙니다.loading
값이 true이므로 두 번째 <div>
가 렌더링되고, lazyRef
에 DOM node 참조값이 담깁니다.lazyRef.current
값이 변경되었는데도 두 번째 훅의 useEffect는 호출되지 않습니다.이제 위에서 했던 "렌더링을 건너뛰는 useEffect는 다음 렌더링 전에는 ref에 대한 어떤 변경도 볼 수 없다"는 말이 이해되시나요?
우리는 컴포넌트가 다른 state들을 다루는 마법의 방식처럼 ref의 변경에 대응할 수 있도록 할 수는 없습니다. useEffect는 ref의 변경을 즉각 감지할 수 없어요. 다만 다음 렌더링이 발생했을 때는 ref
의 변경을 확인할 수 있어서 useEffect 콜백을 실행시켜 줄 수 있겠죠.
말이 길었는데 결국 한 문장으로 정리하자면 다음과 같습니다.
useEffect가 매 렌더링마다 호출되는 게 아니라면(ref의 변경을 계속해서 확인할 수 있는 상태가 아니라면), useEffect에 ref object를 의존성으로 담지 마세요🙅♂️
DOM Node를 참조하기 위한 방법으로 useRef
를 사용하는 대신에, useCallback
을 사용해서 Callback ref를 만드세요! 위의 useGettingHeight
hook을 이를 사용하여 수정해보겠습니다.
const useGettingHeight = () => {
const [height, setHeight] = useState(null);
// ✅ useRef와 useEffect를 지우고 callback ref를 새로 작성
const ref = useCallback((node: HTMLElement) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return [height, ref];
};
function App() {
const [height, ref] = useGettingHeight();
const [lazyHeight, lazyRef] = useGettingHeight();
const loading = useLoading();
return (
<>
<div ref={ref}>ref height: {height.toString()}</div>
{!loading && <div ref={lazyRef}>Lazy ref height: {lazyHeight.toString()}</div>}
</>
);
}
커스텀 훅에서 리턴하는 ref를 callback ref의 형태로 변경한 것을 제외하고는 위의 코드와 동일합니다! 한 번 실행해볼까요?
위 링크에서 직접 코드를 수정해서 결과를 확인해보세요🙌
잘 작동하는군요! 리액트는 이제 ref
(callback ref)가 node에 붙여질 때 이 콜백을 실행할 겁니다. 붙여지는 때도 상관없고, 다른 노드에 다시 붙게 되더라도 상관없어요. 그 때마다 해당 콜백을 실행시켜 줄겁니다. 이렇게 node를 인자로 받는 콜백이 호출되면 setHeight
이 실행되어 업데이트된 height
값을 확인할 수 있게 됩니다.
추가로 useCallback
에 빈 의존성([]
)을 전달한 것을 확인할 수 있는데요. 이렇게 하면 우리가 만든 ref 콜백이 리렌더링 시에도 참조값이 변경되지 않게끔 할 수 있습니다. 따라서 리액트가 매 렌더링마다 불필요하게 콜백을 호출하지 않을 거에요.
ref가 변경되어도 컴포넌트가 새로 렌더링되지는 않으므로, useEffect
는 ref의 변경에 대해 즉각 감지를 할 수 없습니다. 다른 값 변경 등의 이유로 컴포넌트가 렌더링이 새로 됐을 때 비로소 변경된 ref 값을 볼 수 있게 됩니다.
아마 지금까지 ref를 useEffect에 의존성으로 담으며 찜찜함을 느끼셨을 분들이 있을 겁니다. state를 의존성으로 담았을 때와는 미묘하게 다른 현상(값의 변경이 확인이 안되는)을 겪으신 분들, 이제 자신있게 작성하실 수 있겠죠?
useEffect
가 아니라면, 사용할 ref는 useCallback
+ callback ref
로 작성하기useEffect
라면, 그대로 useRef
의 값을 의존성으로 담아도 됨아래 리액트 공식 문서의 Hooks FAQ에 관련된 내용이 좀 더 상세하게 나와있습니다. 더 명확한 이해를 위해 추가로 읽어보시는 것을 권장합니다🙆♂️
정말 이해가 안되었던 내용인데 이해가 쏙쏙되는 글인것 같가요
감사합니다