본문으로 건너뛰기

채팅

채팅, AI 스트림, 로그 및 기타 역방향 피드는 상단에 앵커링된 표준 목록과 다른 스크롤 동작 규칙을 따릅니다. 일반적으로 새 출력은 끝에 나타나고 이전 기록은 시작 부분에 추가되며, 사용자가 이미 최신 항목을 읽고 있을 때만 뷰포트가 새 출력을 따라가야 합니다.

TanStack Virtual은 끝 앵커링으로 이 동작을 지원합니다.

const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index]!.id,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
overscan: 6,
})

전체 React 채팅 예제를 참조합니다.

동작

최신 메시지에서 시작하기

스크롤 요소가 마운트되면 scrollToEnd()를 한 번 사용합니다.

React.useLayoutEffect(() => {
virtualizer.scrollToEnd()
}, [virtualizer])

서버에서 렌더링되거나 복원된 화면에서는 initialOffsetinitialMeasurementsCache를 사용할 수도 있지만, 대부분의 채팅 화면은 마운트 후 명령형으로 최신 항목까지 스크롤하여 시작합니다.

이전 기록을 앞에 추가할 때 안정적으로 유지하기

사용자가 상단 근처까지 스크롤하면 이전 메시지를 불러와 배열 앞에 추가합니다. anchorTo: 'end'를 사용하면 TanStack Virtual은 데이터가 변경되기 전에 보이는 항목을 포착하고, 앞에 메시지가 추가된 뒤 동일한 키를 가진 항목을 찾은 다음, 메시지가 시각적으로 같은 위치에 유지되도록 스크롤 오프셋을 조정합니다.

setMessages((current) => [...olderMessages, ...current])

이 동작이 제대로 작동하려면 안정적인 키가 필요합니다.

getItemKey: (index) => messages[index]!.id

채팅 기록에 인덱스 키를 사용하지 마십시오. 앞에 메시지를 추가하면 기존 메시지가 모두 새 인덱스로 이동하므로, 인덱스 키로는 업데이트 전후의 동일한 메시지를 식별할 수 없습니다.

끝에 고정된 경우에만 추가된 출력 따라가기

새 메시지가 도착했을 때 사용자가 이미 끝에 있었다면 뷰포트가 끝에 계속 고정되도록 followOnAppend를 설정합니다.

followOnAppend: true

사용자가 기록을 읽기 위해 위로 스크롤한 경우에는 메시지가 추가되어도 현재 위치에서 벗어나지 않습니다. scrollEndThreshold는 끝에서 얼마나 가까워야 고정된 것으로 간주할지 제어합니다.

scrollEndThreshold: 80

따라가는 동작에 애니메이션을 적용하려면 스크롤 동작을 지정합니다.

followOnAppend: 'smooth'

스트리밍 출력을 고정된 상태로 유지하기

스트리밍 채팅 응답은 대개 마지막 항목의 크기를 여러 번 늘립니다. 끝 앵커링 모드에서 측정된 크기가 변경되기 전에 뷰포트가 끝에 고정되어 있다면, 버추얼라이저가 크기 차이만큼 조정하여 하단이 최신 출력에 계속 붙어 있도록 합니다.

이 동작은 일반적인 동적 측정 패턴과 함께 작동합니다.

{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
}}
>
<Message message={messages[virtualItem.index]!} />
</div>
))}

일반적인 스크롤 컨테이너와 일반적인 항목 순서를 사용합니다. flex-direction: column-reverse, 반전 변환 또는 직접 구현한 scrollTop += delta 방식의 앞쪽 추가 보정은 필요하지 않습니다.

<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
}}
>
<Message message={messages[virtualItem.index]!} />
</div>
))}
</div>
</div>

프로덕션 체크리스트

  • getItemKey에 안정적인 메시지 ID를 사용합니다.
  • 스크롤 요소에 고정 높이와 overflow: auto를 지정합니다.
  • 메시지 높이가 동적이면 measureElement를 호출합니다.
  • 앞쪽 추가 시 안정성과 스트리밍 중 하단 확장을 위해 anchorTo: 'end'를 사용합니다.
  • 최신 위치에 있을 때만 새 출력을 따라가야 한다면 followOnAppend를 사용합니다.
  • 사용자가 기록을 읽고 있을 때 "최신 항목으로 이동" UI를 표시하려면 isAtEnd()를 사용합니다.
  • 네트워크 로딩 상태는 버추얼라이저 외부에서 관리하고 데이터를 일반적인 방식으로 앞이나 뒤에 추가합니다.

API 레퍼런스