useEffect 의존성에 ref를 담을 때마다 찜찜하신 분들을 위해
🥱 TLDR;
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 object를 의존성으로 주입하는 것의 진짜 문제
위 예시에서 우리는 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>
가 렌더링되지 않았으므로 값이 없습니다.
- 첫 번째
- 첫 렌더링 이후 실행되는 useEffect 콜백
useGettingHeight
의ref.current
를 의존성으로 가지는useEffect
콜백이 실행됩니다.- Note.
ref.current
의존성 때문에 호출되는 것이 아닙니다! 모든 useEffect는 의존성과 관계없이 컴포넌트 첫 렌더링 이후 한 번 실행됩니다.
- Note.
useLoading
의useEffect
콜백도 실행됩니다.
- useEffect에 의해 변경되는 state
- useEffect 콜백이 처음으로 실행될 때, 첫 번째
ref.current
는 DOM node 참조값을 가지고 있으므로setHeight
을 호출하여height
state를 업데이트해줍니다. - 두 번째
lazyRef.current
는 값이 없으므로setHeight
이 호출되지 않습니다. loading
값이true
가 됩니다.
- useEffect 콜백이 처음으로 실행될 때, 첫 번째
- state 변경으로 인한 컴포넌트 리렌더링
- 리렌더링 시, 첫 번째
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에 관련된 내용이 좀 더 상세하게 나와있습니다. 더 명확한 이해를 위해 추가로 읽어보시는 것을 권장합니다🙆♂️
참고
Author And Source
이 문제에 관하여(useEffect 의존성에 ref를 담을 때마다 찜찜하신 분들을 위해), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@shmoon2917/useEffect-의존성에-ref를-담을-때마다-찜찜하신-분들을-위해저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)