출처 1: https://tkdodo.eu/blog/avoiding-use-effect-with-callback-refs
출처 2: https://www.nikhilsnayak.dev/blogs/the-auto-scroll-list-component
ref는 이론적으로 임의의 값을 저장할 수 있는 변경 가능한 컨테이너이지만 DOM 노드에 액세스하는 데 가장 많이 사용됩니다.
const ref = React.useRef(null)
return <input ref={ref} defaultValue="Hello world" />
ref는 내장 원시형에서 예약된 속성으로, React가 렌더링된 후 DOM 노드를 저장합니다. 컴포넌트가 언마운트되면 null로 다시 설정됩니다.
대부분 상호작용의 경우, React가 자동으로 업데이트를 처리하기 때문에 기본 DOM 노드에 액세스할 필요가 없습니다.
지금 당장 렌더링된 입력 요소에 초점을 맞추려면 어떻게 해야 할까요?
글쎄요, 제가 본 대부분의 코드는 이렇게 하려고 시도했습니다.
const ref = React.useRef(null)
React.useEffect(() => {
ref.current?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
대체로 괜찮고 어떤 규칙도 위반하지 않습니다. 빈 종속성 배열은 내부에서 사용되는 유일한 것이 ref이고 안정적이기 때문에 괜찮습니다. useEffect는 "on mount"에서 한 번 실행됩니다. 그때쯤이면 React는 이미 DOM 노드로 ref를 채웠으므로, 우리는 해당 노드에 focus할 수 있습니다. (구체적으로, useEffect가 실행될 때 ref가 "채워진다"고 가정합니다.)
하지만 이것은 최선의 방법은 아니며 일부 고급 상황에서는 몇 가지 주의 사항이 있을 수 있습니다. 예를 들어 렌더링을 지연하거나 다른 사용자 상호 작용 후에만 입력을 표시하는 사용자 지정 컴포넌트에 ref를 전달하는 경우라면 ref의 내용은 useEffect가 실행될 때에도 여전히 null이고 아무것도 실행되지 않습니다.
function App() {
const ref = React.useRef(null)
React.useEffect(() => {
// 🚨 ref.current is always null when this runs
ref.current?.focus()
}, [])
return <Form ref={ref} />
}
const Form = React.forwardRef((props, ref) => {
const [show, setShow] = React.useState(false)
return (
<form>
<button type="button" onClick={() => setShow(true)}>
show
</button>
// 🧐 ref is attached to the input, but it's conditionally rendered
// so it won't be filled when the above effect runs
{show && <input ref={ref} />}
</form>
)
})
일어나는 일은 다음과 같습니다.
문제는 effect가 Form의 렌더 함수에 "바인딩"되어 있는 반면, 실제로 표현하고 싶은 것은 "폼이 마운트될 때"가 아닌 "입력이 렌더링될 때 입력에 포커스를 맞추는 것(!)"입니다.
여기서 Callback ref가 작용합니다. ref에 대한 타입 선언을 살펴본 적이 있다면 참조 객체를 전달할 수 있을 뿐만 아니라 함수도 전달할 수 있다는 것을 알 수 있습니다.
type Ref<T> = RefCallback<T> | RefObject<T> | null
개념적으로, 저는 React 요소의 ref를 컴포넌트가 렌더링된 후 호출되는 함수로 생각하고 싶습니다. 이 함수는 렌더링된 DOM 노드를 인수로 전달받습니다. React 요소가 언마운트되면 null 로 한 번 더 호출됩니다.
따라서 React useRef(RefObject)에서 요소로 ref를 전달하는 것은 다음의 구문적 설탕일 뿐입니다.
<input
ref={(node) => {
ref.current = node;
}}
defaultValue="Hello world"
/>
그리고 그 함수들은 렌더링 후에 실행되고, 안에서 실행하는 것은 전혀 문제가 없습니다. 이런 지식을 가지고 있는데, 노드에 직접 접근할 수 있는 콜백 참조 내부의 input에 focus하는 것을 막는 것은 무엇일까요?
<input
ref={(node) => {
node?.focus()
}}
defaultValue="Hello world"
/>
React는 렌더링 할 때마다 이 함수를 실행합니다. 따라서 입력에 자주 focus하는 것이 괜찮지 않다면 React에게 원할 때만 실행하라고 말해야 합니다.
다행히도 React는 참조 안정성을 사용하여 콜백 참조를 실행해야 하는지 여부를 확인합니다. 즉, 동일한 참조를 전달하면 실행이 건너뛰어집니다.
그리고 useCallback이 등장하는 곳이 바로 여기입니다. 왜냐하면 그것이 우리가 함수가 불필요하게 생성되지 않도록 보장하는 방법이기 때문입니다. 아마도 그것이 그들을 callback-refs라고 불리는 이유일 것입니다.
const ref = React.useCallback((node) => {
node?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
이것을 초기 버전과 비교하면, 코드가 적고 두 개가 아닌 하나의 후크만 사용합니다. 또한 콜백 참조는 마운트하는 구성 요소가 아닌 DOM 노드의 수명 주기에 바인딩되기 때문에 모든 상황에서 작동합니다. 또한, (개발 환경에서 실행할 때) strict 모드에서 두 번 실행되지 않으며, 이는 많은 사람에게 중요한 것으로 보입니다.
당신은 그것을 사용하여 모든 종류의 부작용을 실행할 수 있습니다, 예를 들어, 그 안에서 setState를 호출합니다 .
function MeasureExample() {
const [height, setHeight] = React.useState(0)
const measuredRef = React.useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
)
}
따라서 렌더링된 후 DOM 노드와 직접 상호 작용해야 하는 경우 useRef + useEffect를 직접 사용하지 말고 대신 콜백 참조를 사용하는 것을 고려하세요. (오 좋은 꿀팁이군)
저는 RAG 챗봇에 대해 하단으로 자동 스크롤이 되는 기능을 구현했습니다.
콜백 참조에 한 가지 문제점은 useEffect와 달리 cleanup 함수를 반환할 수 없다는 것입니다. 대신 구성 요소가 DOM에서 언마운트되면 콜백 참조가 null값과 함께 호출되고, 이 값을 확인하여 정리를 수행할 수 있습니다. 저에게는 약간 번거로웠습니다.
다행히도 React 19에서는 이 문제를 해결합니다. React 19를 사용하면 useEffect의 정리 함수와 정확히 같은 방식으로 동작하는 정리 함수를 반환할 수 있습니다. (오호...!)
<input
ref={(ref) => {
// ref created
// NEW: return a cleanup function to reset
// the ref when element is removed from DOM.
return () => {
// ref cleanup
};
}}
/>
모든 것이 제 편이었기 때문에 이 접근 방식을 시도해 보았고, 즉시 마음에 들었습니다. 또한 초기 구현과 별도로 사용자 상호 작용을 존중하고 자동 스크롤 동작을 일시 중지하는 동작을 하나 더 추가했습니다.
이 아이디어는 @samselikoff가 "Distinguishing Between Human and Programmatic Scrolling"에서 가르쳤습니다. 저는 이 접근 방식을 𝕏에 게시했고 , 저와 마찬가지로 많은 React 개발자가 이를 좋아하는 듯했습니다.
그러니, 처음부터 만들어 봅시다 AutoScrollList! 이 게시물을 더 잘 이해하려면 위에 링크된 영상을 살펴보는 것이 좋습니다.
먼저, 최종 목표를 이해해 보겠습니다. 목록의 내용이 변경될 때마다 자동으로 아래로 스크롤되는 구성 요소가 필요합니다. 예를 들어, 새 목록 항목이 추가되거나 기존 목록 항목이 업데이트될 때입니다.
또한, 인터페이스와의 사용자 상호작용을 존중해야 합니다. 사용자가 위로 스크롤하면 자동 스크롤 동작이 일시 중지되어야 합니다. 그러나 다시 맨 아래로 스크롤하면 자동 스크롤 동작이 재개되어야 합니다.
기존 채팅 UI는 다음과 같습니다.
import { AssistantMessage, UserMessage } from './message';
import { useContinueConversation } from './use-continue-conversation';
import { UserInput } from './user-input';
export default function App() {
const { messages, continueConversation, isPending } =
useContinueConversation();
return (
<div>
<ul className='mb-4 h-[50vh] space-y-4 overflow-auto p-4'>
{messages.map((message) => {
return (
<li key={message.id}>
{message.role === 'assistant' ? (
<AssistantMessage>{message.value}</AssistantMessage>
) : (
<UserMessage>{message.value}</UserMessage>
)}
</li>
);
})}
</ul>
<UserInput action={continueConversation} isPending={isPending} />
</div>
);
}
위 코드의 구현 세부 사항에 대해 걱정할 필요가 없습니다. 스크롤 구성 요소를 시작하는 데 도움이 되는 보일러플레이트일 뿐입니다.
배열 messages은 모든 데이터를 보관하고, 각 메시지를 각각 AssistantMessage 또는 UserMessage 에 매핑합니다. 현재 Chat UI에는 자동 스크롤 동작이 없습니다.
📚 사전 지식 1. scrollTop, scrollHeight, clientHeight 구분. scrollHeight는 요소의 전체 콘텐츠 높이 (스크롤 포함), clientHeight는 화면에 보이는 영역의 높이, scrollTop은 현재 스크롤 위치를 의미한다. 따라서 스크롤이 맨 아래인지 확인하고 싶다면 scrollHeight - scrollTop == clientHeight 가 true가 되는지 보면 된다. 만약 현재 스크롤 위치가 맨 아래에서 100px 이내인 경우를 확인하고 싶다면 scrollHeight - scrollTop - clientHeight < 100 인지를 보면 된다.
📚 사전 지식 2. MutationObserver는 DOM(Document Object Model)의 변화를 비동기적으로 감지할 수 있는 웹 API이다. 이 API는 DOM 요소의 구조, 속성, 텍스트 내용의 변경을 추적하는 데 유용하다.
요구 사항을 해결하는 방법은 여러 가지가 있습니다. 한 가지 방법은 useEffect에 messages를 종속성으로 추가하여 메시지가 변경될 때마다 자동 스크롤을 트리거하는 것입니다.
그러나 다른 방법으로 MutationObserver을 사용하여 DOM 변형을 수신하고 스크롤 동작을 트리거합니다.
import { useEffect, useRef } from 'react';
import { AssistantMessage, UserMessage } from './message';
import { useContinueConversation } from './use-continue-conversation';
import { UserInput } from './user-input';
export default function App() {
const { messages, continueConversation, isPending } =
useContinueConversation();
const autoScrollListRef = useRef(null);
useEffect(() => {
const list = autoScrollListRef.current;
if (!list) return;
const observer = new MutationObserver(() => {
list.scrollTo({ top: list.scrollHeight });
});
observer.observe(list, {
subtree: true,
childList: true,
characterData: true,
});
return () => observer.disconnect();
}, []);
return (
<div>
<ul className='mb-4 h-[50vh] space-y-4 overflow-auto p-4'>
<ul ref={autoScrollListRef} className='mb-4 h-[50vh] space-y-4 overflow-y-auto p-4'>
{messages.map((message) => {
return (
<li key={message.id}>
{message.role === 'assistant' ? (
<AssistantMessage>{message.value}</AssistantMessage>
) : (
<UserMessage>{message.value}</UserMessage>
)}
</li>
);
})}
</ul>
<UserInput action={continueConversation} isPending={isPending} />
</div>
);
}
우리는 ul 요소에 ref를 참조했습니다. ul 요소는 고정된 높이를 가지고 있고, overflow-y
는 'auto' 또는 'scroll' 로 설정되어있어야 합니다.
다음과 같은 일이 발생합니다.
이 접근 방식은 효과가 있지만, 큰 문제가 있습니다. 사용자 상호작용을 존중하지 않는다는 것입니다. 사용자가 위로 스크롤하면 자동 스크롤이 여전히 뷰를 아래로 강제로 이동시켜 짜증이 날 수 있습니다.
또한, 우리는 명령형 DOM 조작에 useRef+ useEffect조합을 사용하고 있는데, 이는 Callback Ref를 사용하기에 좋은 신호입니다.
다음 버전에서는 이러한 문제를 해결하겠습니다.
이제 작동 방식을 이해했으므로 코드를 수정해봅시다.
import { useCallback, useState } from 'react';
export function AutoScrollList({ className, ...rest }) {
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const autoScrollListRef = useCallback(
(list) => {
const observer = new MutationObserver(() => {
if (shouldAutoScroll) {
list.scrollTo({ top: list.scrollHeight });
}
});
observer.observe(list, {
subtree: true,
childList: true,
characterData: true,
});
return () => observer.disconnect();
},
[shouldAutoScroll]
);
const handleUserInteraction = (e) => {
const { scrollHeight, clientHeight, scrollTop } = e.currentTarget;
const maxScrollHeight = scrollHeight - clientHeight;
if (e.deltaY < 0) {
setShouldAutoScroll(false);
} else if (
e.deltaY > 0 &&
maxScrollHeight - scrollTop <= maxScrollHeight / 2
) {
setShouldAutoScroll(true);
}
};
return (
<ul
ref={autoScrollListRef}
className={`overflow-y-auto ${className}`}
onWheel={handleUserInteraction}
{...rest}
/>
);
}
자동 스크롤 구현은 이전과 거의 동일하지만 이제 shouldAutoScroll 상태를 존중합니다. 가장 큰 차이점은 useRef + useEffect 대신 콜백 참조를 사용한다는 것입니다.
useCallback는 autoScrollListRef 콜백이 모든 렌더링에서 재생성되지 않도록 보장합니다. 이는 콜백 참조가 참조 동등성에 의존하기 때문에 중요합니다. 불필요하게 재생성하면 반복 실행으로 이어질 수 있습니다.
반면 useRef+ useEffect 접근 방식에서는 AutoScrollList 컴포넌트가 마운트될때 MutationObserver 설정이 발생합니다. 다시말해, DOM 노드를 사용할 수 있을 때가 아닙니다(!). 이 미묘한 구별은 이 경우 콜백 참조를 더 깔끔하고 효율적으로 만듭니다.
[사용자 상호 작용 존중]
사용자 상호작용을 처리하고 필요할 때 자동 스크롤을 일시 중지하려면 영상에서 설명한대로 wheel 이벤트를 사용합니다. (프로그래밍적 스크롤과 사람이 실행하는 스크롤을 구분하기 위해서)
handleUserInteraction 함수의 동작 방식은 다음과 같습니다.
shouldAutoScroll 상태는 useCallback 종속성 배열에 포함되어 상태가 변경될 때마다 콜백 참조가 업데이트되어 동작의 일관성이 유지됩니다.
이 버전은 몇 가지 주요 문제를 해결했지만 두 가지 과제를 안고 있습니다.
최종 버전에서 이러한 문제를 해결해 견고하고 다재다능한 AutoScrollList구성 요소를 구축해 보세요! 🚀
이 버전에서는 useCallback. useState 가 필요하지 않습니다. React 세계 밖으로 나가서 원시인처럼 바닐라 DOM API로 모든 것을 직접 돌리기 때문입니다. AutoScrollList 구성 요소 자체는 React 구성 요소이지만 스크롤 동작을 제어하는 로직은 React 외부에서 구현됩니다.
코드를 살펴보고 무엇을 변경했는지 논의해 보겠습니다.
export function AutoScrollList({ className, ...rest }) {
return (
<ul
ref={autoScrollListRef}
className={`overflow-y-auto ${className}`}
{...rest}
/>
);
}
function autoScrollListRef(list: HTMLUListElement) {
let shouldAutoScroll = true;
let touchStartY = 0;
let lastScrollTop = 0;
const checkScrollPosition = () => {
const { scrollHeight, clientHeight, scrollTop } = list;
const maxScrollHeight = scrollHeight - clientHeight;
const scrollThreshold = maxScrollHeight / 2;
if (scrollTop < lastScrollTop) {
shouldAutoScroll = false;
} else if (maxScrollHeight - scrollTop <= scrollThreshold) {
shouldAutoScroll = true;
}
lastScrollTop = scrollTop;
};
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
shouldAutoScroll = false;
} else {
checkScrollPosition();
}
};
const handleTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
const touchEndY = e.touches[0].clientY;
const deltaY = touchStartY - touchEndY;
if (deltaY < 0) {
shouldAutoScroll = false;
} else {
checkScrollPosition();
}
touchStartY = touchEndY;
};
list.addEventListener('wheel', handleWheel);
list.addEventListener('touchstart', handleTouchStart);
list.addEventListener('touchmove', handleTouchMove);
const observer = new MutationObserver(() => {
if (shouldAutoScroll) {
list.scrollTo({ top: list.scrollHeight });
}
});
observer.observe(list, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
list.removeEventListener('wheel', handleWheel);
list.removeEventListener('touchstart', handleTouchStart);
list.removeEventListener('touchmove', handleTouchMove);
};
}
트레이드오프를 이해하고 작업에 적합한 도구를 활용함으로써, 우리는 사용자 상호작용을 존중하는 매우 효율적이고 사용자 친화적인 자동 스크롤 동작을 만들었습니다. 채팅 UI나 동적 콘텐츠 목록을 빌드하든, 이 접근 방식은 매끄럽고 원활한 사용자 경험을 보장합니다.