Scroll method와 리액트에서 주의할 점

2023.10.08

Table of Contents

자바스크립트로 화면의 스크롤을 조작할 수 있다. 스크롤과 관련된 여러 web API가 있어 이를 정리하고 리액트와 함께 스크롤 이벤트를 사용하면 발생했던 문제에 대해 글을 작성해보고자 한다.

[Web API] Scroll Methods

자주 사용되는 스크롤 메서드들은 3개 정도이다. 이에 대해 살펴보기 전에 scroll()scrollTo()는 기능 면에서는 거의 차이가 없고 CanIUse 사이트를 확인해본 결과 크로스브라우징 면에서도 큰 차이점을 발견하지 못했다.

scrollTo()

해당 메서드는 문서 내의 특정 위치로 스크롤한다.
scroll() 메서드는 지정한 엘리먼트 내부의 특정 위치로 스크롤을 해준다는 차이점이 있다.
window를 엘리먼트로 지정한 경우 두 메서드의 차이점은 없다.
(ex. 문서 최상단으로 스크롤 이동)
const scrollTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); };
(ex. 문서 최하단으로 스크롤 이동)
const scrollToBottomWithBody = () => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth', }); };
scroll to top
scroll to top
scroll to bottom
scroll to bottom

scrollBy()

해당 메서드는 현재 화면에서 특정 위치만큼 스크롤한다.
현위치에서 이동한다는 점에서 상대적인 이동이 가능하다.
(ex. 현재 위치에서 아래로 6000px만큼 이동)
const scrollByEvent = () => { window.scrollBy({ top: 6000, behavior: 'smooth' }); };
notion image

scrollIntoView()

해당 메서드는 지정한 엘리멘트가 사용자에게 표시되도록 상위 컨테이너를 스크롤한다.
lastChild를 TS 에러 해결을 위해 lastElementChild로 작성했다.
(ex. react 공식문서에서 볼 수 있는 예시로 ref를 활용하여 해당 요소로 스크롤을 이동)
const listRef = useRef<HTMLUListElement>(null); const scrollToBottomWithRef = () => { listRef.current?.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); };
notion image

리액트에서 활용

리액트 공식문서에서는 scrollIntoView()를 활용하는 상황에서 ref로 돔에 접근하는 방식을 추천하고 있다. 그러나 리액트와 스크롤 이벤트를 함께 사용하는 과정에서 발생하는 문제점이 있다. 아래의 문제 이미지를 보면 새로운 댓글에 접근하지 않고 그전 요소로 스크롤 이벤트가 발생한다.
설명하자면 리렌더링 이전에 스크롤 이벤트가 발생하여 업데이트 이전 마지막 요소로 스크롤 이벤트가 실행되는 때문에 발생하는 문제이다.
리액트와 스크롤 이벤트의 문제
리액트와 스크롤 이벤트의 문제
해당 문제를 해결한 모습
해당 문제를 해결한 모습

주의할 점

문제를 발견했을 당시에도 댓글을 작성하면 스크롤을 최하단으로 내리는 기능을 구현하고자 했고 flushSync()에 대해 알지 못했다. state와 useEffect를 하나씩 새로 작성해서 렌더링이 완료되었을 때 스크롤 이벤트가 발생하도록 기능을 구현했으나 불필요한 내용들이 추가되면서 코드의 가독성이 떨어졌다고 느낀다.
예시로 작성한 코드는 이전 팀프로젝트를 진행하며 작성했던 코드에 비해 간단하다. 팀 프로젝트에서는 댓글을 작성한 뒤 상태를 업데이트 하기 위해 해당 포스트를 다시 fetch하는 방법을 택했었기 때문이다. 여러 비동기 코드와 상태 업데이트가 일어나고 컴포넌트 간의 함수 호출을 발생했다.
... const [comment, setComment] = useState(''); const [isSubmitted, setIsSubmitted] = useState(false); const handleSubmit = async () => { setCommentList(something); setComment(''); setIsSubmitted(true); }; useEffect(() => { if (isSubmitted) { scrollToBottom(); setIsSubmitted(false); } }, [isSubmitted]); ...
이 방법을 택한 이유로는 리액트 18부터는 automatic batching이 적용되면서 리렌더링을 최적화했고 batching의 순서와 방식을 모두 이해하여 코드를 작성하기에는 렌더링 방식에 너무 의존적인 코드르 작성할 것이라고 판단했다. 무엇보다 이때는 알지 못했던 기능이 있었다.

flushSync()

리액트 공식문서에서 설명하는 상태 업데이트를 동기적으로 해결하는 방법이다. automatic batching으로 렌더링이 여러번 일어나는 경우를 피했지만 내가 경험했던 상황에서 강제로 동기적인 렌더링이 필요한 경우에 활용하면 좋을 API이다.
불필요한 state와 useEffect를 정리할 수 있고 리렌더링 이후에 실행할 함수를 flushSync 다음으로 작성해 가독성도 좋은 모습이다.
import { flushSync } from 'react-dom'; const handleSubmit = async () => { flushSync(() => { setCommentList(something); }); listRef.current?.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); };

마치며

스크롤 메서드를 정리하며 추가적으로 언급하면 좋을 것 같은 경험을 작성하고자 했는데 내가 하고 싶은 얘기를 많이 한 느낌이다.😅 그래도 다른분들이 같은 문제를 겪을 수 있기 때문에 조금이라도 도움이 되면 좋겠다. (이전에 작성했던 코드는 dig-dig-deep 깃허브에서 확인하실 수 있습니다.)

개인적인 궁금증

개인적으로 flushSyncasync/await와 비슷하다고 생각했고 flushSync()async/await로 수정해서 확인해보기도 했다. 결론부터 말하자면 대체할 수 있는 경우가 있었으나 그렇지 않은 경우가 존재했다. 따로 flushSync를 만들어 모든 경우에 적용되는 API를 작성하고자한 것 같다.

글에 활용한 예시 코드

참조

 

Prev
JS module 그리고 CJS와 ESM의 차이
Next
노션 클로닝 프로젝트 회고