개발여행
리액트에서 사용하는 intersection observer 본문
Intersection observer는 특정 DOM 요소가 트리거로 지정된 다른 DOM 요소와 만났을 때 함수를 실행시켜주는 웹 API 기능이다. 트리거로 지정한 DOM 요소가 있어야 제대로 작동하는 만큼, 이런저런 상태 변화 때문에 리렌더링이 자주 일어나는 리액트 프레임워크에서는 유의해야 할 pitfall이 있다.
사라진 타겟
Intersection observer를 활용하기 위해서는 observer가 바라볼 타겟 요소가 필요한데, 리액트에서는 일반적으로 querySelector 대신 useRef 훅에 많이 의존하게 된다.
const Component = () => {
const ref = useRef(null); // DOM 요소를 저장할 변수
return <div ref={ref}>내 컴포넌트</div>; // DOM 요소에 ref 상태를 prop으로 전달
};
이렇게 DOM 요소는 ref 상태에 저장하고, intersection observer를 생성해 useEffect 훅에서 observer가 ref 상태에 저장된 DOM 요소를 observe하도록 구현하면 된다.
const Component = () => {
const ref = useRef(null);
const observer = useObserver(); // Observer를 생성해주는 훅을 사용한다고 가정
const [isObserving, setIsObserving] = useState(false);
useEffect(() => {
if (observer && !isObserving) {
observer.observe(ref.current);
setIsObserving(true);
}
}, [observer]);
return <div ref={ref}>내 컴포넌트</div>;
};
그런데 위 코드가 제대로 동작하지 않는 경우가 있다. 리액트의 코드 구조에서 자주 보이는 조건부 렌더링을 사용할 경우에 그렇다.
const Component = () => {
const ref = useRef(null);
const observer = useObserver();
const [isObserving, setIsObserving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (observer && !isObserving && ref.current) {
observer.observe(ref.current);
setIsObserving(true);
}
}, [observer]);
useEffect(() => {
setIsLoading(false); // 비동기 작업이 끝나면 isLoading을 false로 변환
}, []);
// 조건부 렌더링 시나리오 1
if (isLoading) return <div>로딩 중...</div>; // ref.current가 없어서 useEffect 훅에서 observe가 실행되지 않음
// 조건부 렌더링 시나리오 2
if (isLoading) return <div ref={ref}>로딩 중...</div>; // ref.current가 <div>로딩 중...</div>일 때 observe하게 됨
return <div ref={ref}>로딩 완료된 내 컴포넌트</div>;
};
컴포넌트 내에서 fetch와 같은 비동기 액션이 있는 경우, useState 훅으로 로딩 현황에 대한 상태를 저장하고 해당 값에 따라 컴포넌트를 렌더링하곤 한다.
하지만 이렇게 구현하면 비동기 액션이 끝난 후 내가 실제로 타겟팅하고자 하는 DOM 요소가 렌더링되어도 observer는 이미 엉뚱한 타겟을 바라보고 있거나, observe한 요소가 없기 때문에 observer의 콜백이 실행되지 않는다. ref 상태값은 ref를 prop으로 전달받는 컴포넌트가 렌더링된 다음에 값이 채워지고, useEffect 훅이 발동하는 시점은 ref에 값이 채워지기 전이기 때문.
단순히 useEffect 훅의 dependency에 ref.current를 넣어준다고 해결할 수 있는 문제는 아니다. useState와 달리 useRef 상태값의 변화는 리렌더링을 발동시키지 않기 때문이다. useEffect가 실행된 후에 ref에 값에 변화가 있어도 useEffect 훅은 다시 실행되지 않으니, 결국 다음 리렌더링을 기다릴 수밖에 없다.
useCallback으로 해결
타겟 DOM 요소에 useRef의 ref 객체 대신 useCallback 함수를 전달하면 조건부 렌더링을 적용해도, 나아가 useEffect 훅을 이용하지 않아도 의도한 대로 동작하는 observer를 구현할 수 있다.
useCallback의 콜백 함수가 전달 받는 node 인자는 참조할 ref prop을 전달한 DOM 요소가 된다. useEffect에서 사용했던 observer를 useCallback 콜백 함수로 옮기면 내가 원하는 요소를 observe할 수 있게 된다.
const Component = () => {
const observer = useObserver();
const [isObserving, setIsObserving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// useRef 대신 useCallback 사용
const ref = useCallback((node) => {
if (node) {
observer.observe(node);
}
}, []);
useEffect(() => {
setIsLoading(false);
}, []);
if (isLoading) return <div>로딩 중...</div>;
return <div ref={ref}>로딩 완료된 내 컴포넌트</div>; // useCallback 함수를 ref로 전달
};
리액트 앱을 만들 땐 렌더링으로 인한 사이드이펙트가 발생하지 않도록 신경써야 할 부분이 많은 것 같다... 컴포넌트의 생명주기를 이해하고 리액트의 훅을 적절히 활용하는 연습을 하다보면 이런 pitfall 정도는 우아하게 피해갈 수 있지 않을까 싶다.
참고
'Frontend' 카테고리의 다른 글
Next.js App Router에서 라우팅 처리하기 (0) | 2024.04.21 |
---|---|
SSR과 RSC의 차이 (0) | 2024.04.18 |
Material UI 시작하기 (0) | 2024.04.11 |
리액트의 memo와 useCallback 사용법 (0) | 2024.04.10 |
STOMP.js로 프론트엔드에서 웹소켓 통신 구현 (0) | 2024.04.05 |