Custom Hook for Intersection Observer

5 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

IntersectionObserver 기능을 재사용하기 위해 커스텀 훅(custom hook)을 만들어보았습니다. 프로젝트에서 필요한 최소한의 기능만 구현하였고, 예전에 사용해봤던 react-intersection-observer 라이브러리의 인터페이스와 유사한 모습으로 기능을 구현했습니다. 해당 라이브러리를 사용해도 되지만, 최대한 외부 의존성을 줄이고 공부할 겸 직접 구현해봤습니다.

커스텀 훅을 만들 때 어떤 함수 인자들을 받을지, 어떤 기능을 제공할지 많이 고민 했는데 다음과 같은 관점에서 react-intersection-observer 라이브러리를 참고하였습니다.

  • 적절한 관심사의 분리
    • 라이브러리를 사용하는 클라이언트 컴포넌트의 기능과 관련된 함수 인자를 받지 않습니다.
    • 뷰 포트(view port)가 누군지, 타겟(target)이 누군지만 확인합니다.
  • 필요한 기능, 유연한 사용성을 제공
    • 타겟을 변경할 수 있도록 레퍼런스 객체를 반환합니다.
    • 타겟이 뷰 포트 내부로 진입했는지 여부만 알려줍니다.

훅을 사용하는 클라이언트는 타겟이 뷰 포트 내부에 진입했는지 여부를 확인하고 적절한 콜백 함수를 호출합니다.

1. Before Applying Custom Hook

How to Test Intersection Observer 포스트에서 사용한 코드를 먼저 살펴보겠습니다. 다음과 같은 부분들이 개선이 필요해보입니다.

  • 전역 변수로 선언된 interscetionObjserver, offset 변수
  • 쿼리 셀렉터(query selector)를 사용해 클래스 명으로 리스트 마지막 엘리먼트(element)를 탐색
  • 쿼리 셀렉터를 사용해 ID로 뷰 포트 엘리먼트를 탐색
  • 조회한 리스트 마지막 객체에 불필요한 플래그 설정
  • 코드의 많은 부분을 차지하는 IntetsectionObserver 관련 로직
import { useCallback, useEffect, useState } from 'react'
import classes from './InfiniteScroll.module.css'
import axios from 'axios'

let intersectionObserver
let offset = 0

export default () => {
    const [pokemons, setPokemons] = useState([])

    const fetchesData = useCallback(async () => {
        const { data } = await axios.get(`https://pokeapi.co/api/v2/pokemon/?offset=${offset}&limit=20`)
        const results = data.results
        if (results.length) {
            results[results.length - 1].isLastItem = true
        }
        offset++
        setPokemons((prevState) => {
            if (prevState.length) {
                prevState[prevState.length - 1].isLastItem = false
            }
            return [].concat(prevState).concat(results)
        })
    }, [])

    useEffect(async () => {
        await fetchesData()
    }, [])

    useEffect(() => {
        intersectionObserver = new IntersectionObserver(
            (entries, observer) => {
                entries.forEach(async (entry) => {
                    if (!entry.isIntersecting) {
                        return
                    }
                    observer.unobserve(entry.target)
                    await fetchesData()
                })
            },
            {
                root: document.querySelector('#viewPort'),
            }
        )
        return () => {
            intersectionObserver.disconnect()
        }
    }, [])

    useEffect(() => {
        const lastItem = document.querySelector('.last-pokemon')
        if (lastItem) {
            intersectionObserver.observe(lastItem)
        }
    }, [pokemons])

    return (
        <div id={'viewPort'} className={classes.viewPort}>
            {pokemons.map((pokemon, index) => (
                <div key={index} className={`${classes.box} ${pokemon.isLastItem ? 'last-pokemon' : ''}`}>
                    {pokemon.name}
                </div>
            ))}
        </div>
    )
}

2. Make Custom Hook of Intersection Observer

IntersectionObserver 클래스에 관련된 코드를 재사용할 수 있도록 다음과 같이 커스텀 훅으로 만들었습니다.

  • 설명은 가독성을 위해 코드에 주석으로 표시하였습니다.
import {useEffect, useMemo, useState} from 'react'

// 테스트 코드에서 사용하는 viewPort 입니다.
let testViewPort: HTMLElement | null

// 테스트 용 viewPort를 반환할 때 존재하지 않으면 body 를 반환합니다.
export const getTestViewPort = () => {
    return testViewPort ? testViewPort : document.body
}

export default () => {

    // 클라이언트가 사용하는 viewPort, targetRef, isInView 는 스테이트(state)로 관리합니다.
    const [viewPort, setViewPort] = useState<any>(null)
    const [targetRef, setTargetRef] = useState<any>(null)
    const [isInView, setInView] = useState<boolean>(false)

    // InterSection Observer 객체를 생성합니다.
    // useMemo 훅을 사용하여 재사용하며 viewPort가 바뀌었을 때만 재정의합니다.
    const intersectionObserver = useMemo<IntersectionObserver>(() => new IntersectionObserver((entries, observer) => {
        // 반복문을 사용해 관찰 대상자들의 상태를 확인합니다.
        entries.forEach((entry, index) => {
            // 관찰 대상자가 view port 내부에 진입했을 때
            if (entry.isIntersecting) {
                // 관찰 대상자가 view port 내부에 진입했음을 상태를 변경하여 알려줍니다.
                setInView(true)
                // 관찰 대상자를 제거합니다.
                observer.unobserve(entry.target)
            }
        })
    }, {
        // 외부에서 지정해준 view port를 사용하지만, 별도로 지정해주지 않았다면 document body를 사용합니다.
        root: viewPort ? viewPort : document.body,
    }), [viewPort])

    // view port 가 변경되면 테스트 용 view port도 변경합니다.
    useEffect(() => {
        testViewPort = viewPort
    }, [viewPort])

    // view 포트가 변경될 때마다 이전에 사용하는 InterSection Observer 객체를 정리합니다.
    useEffect(() => {
        return () => {
            intersectionObserver?.disconnect()
        }
    }, [viewPort])

    // 관찰 대상이 변경될 때마다 실행합니다.
    useEffect(() => {
        // 관찰 대상자가 새롭게 지정되었다면
        if (targetRef) {
            // 새로운 관찰 대상자가 view port 내부에 존재하지 않음을 상태를 변경해서 알려줍니다.
            setInView(false)
            // 새로운 관찰 대상자를 등록합니다.
            intersectionObserver.observe(targetRef)
        }
    }, [targetRef])

    // 클라이언트가 view port, target reference 를 지정할 수 있도록 setter 함수를 제공합니다.
    // 클라이언트가 관찰 대상자의 상태를 확인할 수 있도록 isInView 상태를 제공합니다.
    return {
        viewPort: setViewPort,
        targetRef: setTargetRef,
        isInView,
    }
}

3. Apply Custom Hook

작성한 커스텀 훅을 이전 코드에 적용하면 코드가 어떻게 변경되는지 살펴보겠습니다.

  • 설명은 가독성을 위해 코드에 주석으로 표시하였습니다.
import React, {useCallback, useEffect, useState} from 'react'
import classes from './App.module.css'
import useInViewPort from './hooks/useInViewPort'
import axios from 'axios'

interface Pokemon {
    name: string
}

function App() {

    const [offset, setOffset] = useState<number>(0)
    const [pokemons, setPokemons] = useState<Pokemon[]>([])

    // 커스텀 훅은 클라이언트가 지정할 수 있는 viewPort, targetRef, isInView 상태를 반환합니다.
    const {viewPort, targetRef, isInView} = useInViewPort()

    // offset 이 변경될 때마다 함수를 재정의합니다.
    const fetchData = useCallback(() => {
        axios.get(`https://pokeapi.co/api/v2/pokemon/?offset=${offset}&limit=10`)
            .then(({data: {results}}) => {
                setPokemons((prev) => prev.slice().concat(results))
            })
    }, [offset])

    // 마운트 되는 시점에 데이터를 조회합니다.
    useEffect(() => {
        fetchData()
    }, [])

    // offset 이 변경될 때마다 데이터를 재조회합니다.
    useEffect(() => {
        // offset이 0보다 크면 조회합니다.
        if (offset > 0) {
            fetchData()
        }
    }, [offset])

    // 타겟이 view port 내부로 진입, 새로운 타겟 지정 등으로 isInView 상태가 바뀔 때마다 오프셋을 늘려줍니다.
    useEffect(() => {
        if (isInView) {
            setOffset(prev => prev + 1)
        }
    }, [isInView])

    return (
        // 가장 외부 div 를 view port 로 지정합니다.
        <div ref={viewPort} className={classes.viewPort}>
            {pokemons.map((pokemon, index) => (
                // 리스트를 렌더링할 때 마지막 인덱스에 해당하는 div 를 타겟으로 지정합니다.
                <div key={index} className={classes.box} ref={pokemons.length - 1 === index ? targetRef : null}>
                    {pokemon.name}
                </div>
            ))}
        </div>
    )
}

export default App

4. Apply to Test Code

4.1. setupTests.js

  • 테스트가 실행되기 전에 필요한 로직을 추가하는 스크립트 파일입니다.
  • IntersectionObserver 클래스는 브라우저가 제공하는 Web API 이기 때문에 테스트에 필요한 가짜 클래스를 만들어 등록합니다.
  • 자세한 내용은 How to Test Intersection Observer 포스트를 참고 바랍니다.
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

global.IntersectionObserver = class IntersectionObserver {
    constructor(callback, options) {
        this.viewPort = options.root
        this.entries = []
        this.viewPort?.addEventListener('scroll', () => {
            this.entries.map((entry) => {
                entry.isIntersecting = this.isInViewPort(entry.target)
            })
            callback(this.entries, this)
        })
    }

    isInViewPort(target) {
        return true
    }

    observe(target) {
        this.entries.push({isIntersecting: true, target})
    }

    unobserve(target) {
        this.entries = this.entries.filter((ob) => ob.target !== target)
    }

    disconnect() {
        this.entries = []
    }
}

4.2. App.test.tsx

  • 커스텀 훅에서 관리하는 테스트 전용 viewPort를 사용합니다.
  • 쿼리 셀렉터를 사용하지 않아도 테스트가 가능합니다.
import React from 'react'
import {fireEvent, render, screen} from '@testing-library/react'
import App from './App'
import axios from "axios";
import {getTestViewPort} from "./hooks/useInViewPort";

test('스크롤 다운 이벤트를 수행하면 다음 리스트를 볼 수 있다.', async () => {
    const spyAxios = jest.spyOn(axios, 'get').mockResolvedValueOnce({
        data: {
            results: [
                {name: '1'},
                {name: '2'},
                {name: '3'},
                {name: '4'},
                {name: '5'},
                {name: '6'},
                {name: '7'},
                {name: '8'},
                {name: '9'},
                {name: '10'},
            ],
        },
    })
    jest.spyOn(axios, 'get').mockResolvedValueOnce({
        data: {
            results: [{name: '11'}, {name: '12'}],
        },
    })
    render(<App/>)
    expect(await screen.findByText('10')).toBeInTheDocument()

    fireEvent.scroll(getTestViewPort(), {target: {scrollY: 500}})

    expect(await screen.findByText('11')).toBeInTheDocument()
    expect(spyAxios).toHaveBeenCalledTimes(2)
    expect(spyAxios).toHaveBeenNthCalledWith(1, 'https://pokeapi.co/api/v2/pokemon/?offset=0&limit=10')
    expect(spyAxios).toHaveBeenNthCalledWith(2, 'https://pokeapi.co/api/v2/pokemon/?offset=1&limit=10')
})

CLOSING

이번에 만든 커스텀 훅은 더 개선할 수 있는 여지가 있어 보입니다.

  • IntersectionObserver 클래스의 세부적인 옵션을 사용
  • 타겟 엘리먼트 별로 뷰 포트에 진입한 여부를 별도로 관리

지금 필요한 기능은 이 정도 구현 수준으로 커버가 가능하기 때문에 기능을 더 추가하진 않았습니다. IntersectionObserver 클래스의 기능을 커스텀 훅으로 만들 때 이런 방법도 있다는 것을 참고하셔서 더 좋은 코드로 발전시키길 바랍니다.

Infinite Scroll Example

TEST CODE REPOSITORY

REFERENCE

댓글남기기