COINSS: React Custom Hooks로 Scroll Event 구현하기

TEO·2021년 5월 8일
29

COINSS

목록 보기
4/4
post-thumbnail

Scroll Event를 Custom Hook으로 만들었던 방법에 대해 설명 하겠습니다.

일반적인 Scroll Event

일반적으로 Scroll Event를 감지하기 위해서는 addEventListener('scroll', [event함수])을 이용하면 됩니다.
변경할 DOM을 설정하고(document.querySelector()) 이벤트리스너로 변경시켜주면 쉽게 페이지가 변경될때 마다 데이터를 변경시킬 수 있습니다.

const foo = document.querySelector('#foo');

window.addEventListener('scroll', (e) => {
  const value = window.scrollY;
  foo.style.left = value + 'px';
});

이를 React에 적용해 보면 이럴것입니다.
주의! React 성능을 생각안하고 대충 쓴 코드입니다. 실제로는 useEffect를 사용해서 side Effect를 수행시켜줘야 합니다.

function CustomTextInput(props) {
  const foo = useRef(null);

  function handleClick() {
    window.addEventListener('scroll', () => {
      const value = window.scrollY;
      foo.current.style.left = value + 'px';
    }
  }

  return (
    <div>
      <div ref={foo}>
      	나는 💩이다
      </div>
    </div>
  );
}

이 방식은 뭔가 문제가 있어보입니다.

Scroll Event를 작성해야하는 컴포넌트 마다 useRef와 addEventListener를 추가해줘야하니깐요!! React를 왜쓰는 건가요? DOM의 직접적인 조작을 최소화하기 위해서 아닌가요? ㅎㅎ

React 공식문서에도 나와있습니다!

Ref를 남용하지 마세요
ref는 애플리케이션에 “어떤 일이 일어나게” 할 때 사용될 수도 있습니다. 그럴 때는 잠시 멈추고 어느 컴포넌트 계층에서 상태를 소유해야 하는지 신중하게 생각해보세요.

여기서 말한 "어떤일이 일어나게" 가 바로 addEventListener같은 경우일 것이라 추측됩니다. ㅎㅎ 그리고 이것에 대한 해결책은 바로 다음 문장인 "컴포넌트 계층에서 상태를 소유"에 있다고 생각됩니다.

상태를 소유한다? 다른 컴포넌트 계층에서? -> 그렇다면 새로운 컴포넌트에서 상태를 전달해주면 되는건가? -> Custom Hooks를 만들자!

이런식으로 사고를 할 수 있었습니다. 사고난거 아니구요~

꼭 Custom Hooks가 아니어도 괜찮습니다. 새로운 컴포넌트여도 되었어요. 하지만 상태값을 전달해주기 위해선 Custom Hooks가 좋겠죠?

Custom Hooks??

혹시 이게 뭔지 모르시나요.. Hooks는 이 글을 읽으시는 분이라면 아실 것이라 믿습니다.

말그대로 사용자가 만드는 Hook입니다. Custom Hook의 state값을 사용할 컴포넌트의 state값으로 넘겨주는 것이죠.

사용자 정의 Hook은 React의 특별한 기능이라기보다 기본적으로 Hook의 디자인을 따르는 관습이라고 합니다.

이를 사용함으로써 재사용성이 크게 증가합니다. ㅎㅎ 그저 Hook을 가져다 쓰는것 처럼말이죠.

Custom Hooks를 사용할 때 주의 점이 있습니다.
꼭 이름을 'use'로 시작해야한다는 점입니다. Hook은 useState, useReducer 처럼 use를 앞에 붙이잖아요? 이처럼 사용자정의Hooks도 use를 붙여야 한다는 거죠.
React의 관습입니다. 싫으시면 React 안쓰시면 됩니다!

Scroll Event를 Custom Hooks로 구현하기

이제 시작해볼까요?

custom hooks는 state처럼 선언하고, props값을 넣어 전달해주면 됩니다.

이미 github에 누가 만들어둔 Custom Hooks가 있었습니다. 일단 가져와봅시다.

import { useState, useEffect } from "react";

export function useScroll() {
  const [lastScrollTop, setLastScrollTop] = useState(0);
  const [bodyOffset, setBodyOffset] = useState(
    document.body.getBoundingClientRect()
  );
  const [scrollY, setScrollY] = useState(bodyOffset.top);
  const [scrollX, setScrollX] = useState(bodyOffset.left);
  const [scrollDirection, setScrollDirection] = useState();

  const listener = e => {
    setBodyOffset(document.body.getBoundingClientRect());
    setScrollY(-bodyOffset.top);
    setScrollX(bodyOffset.left);
    setScrollDirection(lastScrollTop > -bodyOffset.top ? "down" : "up");
    setLastScrollTop(-bodyOffset.top);
  };

  useEffect(() => {
    window.addEventListener("scroll", listener);
    return () => {
      window.removeEventListener("scroll", listener);
    };
  });

  return {
    scrollY,
    scrollX,
    scrollDirection
  };
}

출처: https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4

이쁘게 만들어 두셨네요 ㅎㅎ

저는 근데 scroll이 위아래로 움직일 때만 필요한 것이기 때문에 scrollY만 남겨두고 필요없는건 지워봅시다.

아 그리고 전 typescript를 쓰니깐 type도 추가해줘야합니다.

import { useState, useEffect } from "react";

export function useScroll() {
  const [scrollY, setScrollY] = useState<number>(0);

  const listener = () => {
    setScrollY(window.pageYOffset);
  };

  useEffect(() => {
    window.addEventListener("scroll", listener);
    return () => {
      window.removeEventListener("scroll", listener);
    };
  });

  return {
    scrollY
  };
}

Hook이기 때문에 return값은 scrollY란 state를 정확히 리턴해줍니다. 또한 받는 쪽에서도 {scrollY}로 받을 것이기 때문에 중괄호로 감싸줍니다.

중간에 보면 useEffect를 써서 sideEffect처리를 해주었죠? 거기다가 addEventListener를 한 후 removeEventListener로 리턴해준 것을 볼 수 있습니다. 이벤트가 끝이 안나면 상태값이 계속 변동된다고 생각되어 virtual DOM이 계속 변경 될 것임을 우려해 그렇게 한 것 같습니다.
그러면 메모리 누출이라는 크나큰 UX적으로 안좋은 상황이 발생할 수 있겠죠??

Oh!No!!

그럼에도 불구하고 메모리 누출이라는 상황이 발생하나 봅니다. 출처 github보면 댓글에 많은 분들이 메모리누출을 방지하고자 여러 방안을 내놓았습니다..

어느 부분에서 문제가 발생하는지 저는 아직 부족해서 모르겠네요 ㅜㅜㅜ

쨋든, 다른 분들의 개선사항을 참고해보도록 하겠습니다. 여러 방법이 있었습니다. useEffect를 한번만 사용해야 하기 때문에 뒤에 []를 추가하는 방법, debounce를 쓰는 방법, Intersection Observer를 쓰는 방법도 있었습니다.

저는 그중에서 lodash의 debounce를 쓰기로 결정했습니다. lodash는 자바스크립트 라이브러리로 객체나 배열을 다룰때 편하게 다루기 좋다고 합니다. 근데 es6가 나오고, 자바스크립트 자체 함수들이 늘어나면서 굳이? 쓸필요 있을까 싶습니다...

그치만 debounce라는 함수는 쓸만해 보여서 깔았습니다. ㅎㅎ 또 한편으로는 Intersection Observer라는 충분한 JS자체 대체제가 있습니다. module을 늘리고 싶지 않으신 분은 Intersection Observer를 쓰는 쪽으로 가셔도 좋을 듯 합니다.

debounce 설치는 다음과 같습니다. typescript용 debounce입니다.

npm i --save-dev @types/lodash

debounce를 import시키고 delay를 설정해서 지정한 시간마다 이벤트를 감지시켜 메모리누출을 막아봅시다.

import { useState, useEffect } from 'react';
import debounce from 'lodash/debounce';

export function useScroll() {
  const [scrollY, setScrollY] = useState<number>(0);

  const listener = () => {
    setScrollY(window.pageYOffset);
  };

  const delay = 15;

  useEffect(() => {
    window.addEventListener('scroll', debounce(listener, delay));
    return () => window.removeEventListener('scroll', listener);
  });

  return {
    scrollY
  };
}

처음에는 10으로 했을 때, 아주 미끄럽게 잘 됐는데,, 빠르게 움직이면 콘솔에서 가끔 오류가 나더라고요?? 그래서 일단 15로 했습니다.
퍼포먼스를 높이려면 10~15사이로 하는게 좋아보입니다.

이렇게 Hooks를 완성시켰습니다. 이 Hook은 이제 컴포넌트에서 다음과 같이 실행하면 끝입니다.

import styled from 'styled-components';
import { useScroll } from '{{Custom Hooks 경로}}'

export function Foo({}: any) {
  const { scrollY } = useScroll();
  
  return (
  	<>
  	  <FooText animate={scrollY}>💩</FooText>
  	</>
  )
}

const FooText = div.styled<{animate: number}>`
  position: absolute;
  top: 0;
  left: ${({animate}) => (animate > 0 ? animate * 0.25 : 0)}px;
  width: 100%;
  height: 100%;
`

이렇게 설정하면 저 Foo는 스크롤할 때마다 좌우로 왔다갔다 할 것입니다! ㅎㅎ

아! 그리고 Typescript로 styled-component를 사용할때 Props를 넘겨줘야 할 경우 저렇게 타입을 명시해줘야 에러가 안나더라고요!

후기

지금까지 React Custom Hooks로 Scroll Event구현하기를 진행해보았습니다. COINSS 프로젝트 진행중에 새로운 기술을 배우고 적용해서 글을 쓰게 되었는데요. 그저 제가 프로젝트를 진행하면서 썼던 코드를 쓰면 재미도 없고 잘 보지도 않는 것 같아서, 예시 코드를 재미있게 만들어 소개해보았습니다.

custom Hooks를 만드려는 분들에게 큰 도움이 되었으면 좋겠네요.ㅎㅎ

또한 앞으로도 COINSS프로젝트를 하면서 어떤 글을 써야할지 고민하게 되는 시간이었습니다. 프로젝트 진행하면서 배운 새로운 기술들을 소개할지, 아니면 그저 진행과정을 설명해야할지...
진행과정은 아무도 안보겠죠??

보통 회사의 기술블로그를 보면 새로운 기술을 이렇게 적용했다~ 식으로 작성하는데...

모르겠습니다. 그냥 쓰고싶은거 쓰면 되겠죠? ㅜㅜ

부족한 글 봐주셔서 감사하고, 작성한 코드에 의문점이나 문제가 있으면 댓글 달아주시면 참고하겠습니다! 감사합니다!

+추가

개발을 하다가

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

다음과 같은 오류메시지를 발견했습니다. Router를 이용해서 다른페이지로 이동하게 되면 발생하더라고요?
useEffect수행이 마운트 해제 되었는데도 수행하려 해서 메모리 누출이 일어난다는 오류 메시지였습니다.

분명 Scroll Event 부분에서 나는 문제였습니다. useEffect는 그 컴포넌트에서 밖에 안썼거든요..
오류 메시지에서 권유하는 cleanup 함수는 useEffect 뒤에 ,[]를 붙이는 걸로 알고 있었습니다. 해당 부분을 수행했는데도 났네요..

검색해보다가 다음과 같은 페이지를 발견했습니다.
https://dev.to/otamnitram/react-useeffect-cleanup-how-and-when-to-use-it-2hbm

cleanup함수의 사용법을 잘 설명해주고 있었습니다. ㅎㅎ 여기서 코드를 조금 배꼈죠...

import { useState, useEffect } from 'react';

export function useScroll() {
  const [scrollY, setScrollY] = useState<number>(0);
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    let mounted = true;
    window.addEventListener('scroll', () => {
      if (mounted) {
        console.log('이벤트 시작');
        setScrollY(window.pageYOffset);
        setLoading(false);
      }
    });
    return () => {
      console.log('이벤트 종료');
      mounted = false;
      // window.removeEventListener('scroll', debounce(listener, delay));
    }
  }, []);

  return {
    scrollY
  };
}

loading state를 만들어 mounted 상태를 판별했습니다. mounted가 false가 되면 addEventListener는 작동을 하지 않죠.

이러니깐 오류메시지가 더이상 뜨지 않았습니다.

혹시라도 저와 같이 메모리 누출 문제로 오류가 생기신 분은 한번 참고해보시길 바랍니다!

참고:
https://ko.reactjs.org/docs/hooks-custom.html
https://ko.reactjs.org/docs/refs-and-the-dom.html
https://shylog.com/react-custom-hooks-scroll-animation-fadein/
https://codesandbox.io/s/useeffect-scroll-event-oolh6?from-embed=&file=/src/index.js:215-246
https://gist.github.com/joshuacerbito/ea318a6a7ca4336e9fadb9ae5bbb87f4
https://dev.to/otamnitram/react-useeffect-cleanup-how-and-when-to-use-it-2hbm

profile
프론트엔드 개발 공부 시작합니다~ 같이 공부해요!

3개의 댓글

comment-user-thumbnail
2021년 7월 11일

사고난거 아니구요~ ㅋㅋㅋ 공부 중에 웃었습니다.

답글 달기
comment-user-thumbnail
2021년 7월 11일

그리고 좋은 글 잘보고 갑니다. 감사합니다.

답글 달기
comment-user-thumbnail
2021년 8월 31일

AS 까지 ㅎㅎ 좋은글 잘 보고 갑니다.

답글 달기