loading...
Cover image for Tracking Scroll Position With React Hooks

Tracking Scroll Position With React Hooks

n8tb1t profile image n8tb1t ・6 min read

Good news, everyone!

Today we are going to learn, how to use JavaScript scroll event listener to track browser scroll position using hooksReact not so old new feature.

Why Do I Need It?

Tracking viewport/element scroll position could be vastly useful and for the most part, the only way to enrich your web project with cool dynamic features, like:

  • Dynamic navigation bars that hide/show during scroll.
  • Sticky elements that remain at the same exact position on scroll change.
  • Dynamic popups and user messages that become visible at a certain point during the page scroll.
  • etc.

Check out some examples here.

Live edit with CodeSandBox:

First of all, I've to notice that most of the time scroll listeners do very expensive work, such as querying DOM elements, reading height/width and so on.

In React context it can lead to a lot of unnecessary re-renders, and as a side effect, have a significant hit on the overall app performance!

In this article, I'll try to solve the aforementioned issues by implementing a useScrollPosition React hook with performance in mind!

So, let's roll!

The final version of the hook will look something like this:

import { useRef, useLayoutEffect } from 'react'

const isBrowser = typeof window !== `undefined`

function getScrollPosition({ element, useWindow }) {
  if (!isBrowser) return { x: 0, y: 0 }

  const target = element ? element.current : document.body
  const position = target.getBoundingClientRect()

  return useWindow
    ? { x: window.scrollX, y: window.scrollY }
    : { x: position.left, y: position.top }
}

export function useScrollPosition(effect, deps, element, useWindow, wait) {
  const position = useRef(getScrollPosition({ useWindow }))

  let throttleTimeout = null

  const callBack = () => {
    const currPos = getScrollPosition({ element, useWindow })
    effect({ prevPos: position.current, currPos })
    position.current = currPos
    throttleTimeout = null
  }

  useLayoutEffect(() => {
    const handleScroll = () => {
      if (wait) {
        if (throttleTimeout === null) {
          throttleTimeout = setTimeout(callBack, wait)
        }
      } else {
        callBack()
      }
    }

    window.addEventListener('scroll', handleScroll)

    return () => window.removeEventListener('scroll', handleScroll)
  }, deps)
}

Let's break it down, and see, what is actually going on under the hood.

To support server-side rendering (SSR) and prevent unnecessary bugs, we need to check if the DOM is ready, and the window context exists. The easiest way to do so - is by checking if the window is defined.

const isBrowser = typeof window !== `undefined`

Now I need a simple function to get the current scroll position:

function getScrollPosition({ element, useWindow }) {
  if (!isBrowser) return { x: 0, y: 0 }

  const target = element ? element.current : document.body
  const position = target.getBoundingClientRect()

  return useWindow
    ? { x: window.scrollX, y: window.scrollY }
    : { x: position.left, y: position.top }
}

Here we check if it runs inside the browser otherwise, just return { x: 0, y: 0 } default values.

The next part is straight forward, we check if the user requested the scroll position of the entire page or any specific element inside it.

const target = element ? element.current : document.body

the element is passed into the function by its reference, created with useRef hook, so we access it by using the element.current value.

There are a lot of ways we can use in order to get the current scroll position.
But the modern ones and the most mobile-friendly are window.scroll and target.getBoundingClientRect(). They slightly differ in performance and each one has its uses, so we'll let the user decide which implementation he wants to use, by introducing the useWindow parameter swither.

Note, though, that Window.scroll[X|Y] is not supported in IE(11 or below). IE9 and below should (in most cases) not be supported anymore because using them means no security updates for browser or OS. But, If you want to support this browser, replace it by Window.page[X|Y]Offset.

The getBoundingClientRect() is a powerful method to get the size and the position of an element's bounding box, relative to the viewport.

According to caniuse it's supported by 98.66% of all modern browsers, including IE9+.

Now, when we have, the helper functions, let's look into the hook itself.

export function useScrollPosition(effect, deps, element, useWindow, wait) {
  const position = useRef(getScrollPosition({ useWindow }))

  let throttleTimeout = null

  const callBack = () => {
    const currPos = getScrollPosition({ element, useWindow })
    effect({ prevPos: position.current, currPos })
    position.current = currPos
    throttleTimeout = null
  }

  useLayoutEffect(() => {
    const handleScroll = () => {
      if (wait) {
        if (throttleTimeout === null) {
          throttleTimeout = setTimeout(callBack, wait)
        }
      } else {
        callBack()
      }
    }

    window.addEventListener('scroll', handleScroll)

    return () => window.removeEventListener('scroll', handleScroll)
  }, deps)
}

In order to store the current position coordinates, let's introduce the stateful position variable.

const position = useRef(getScrollPosition({ useWindow }))

Note, that I'm using the useRef() and not useState(). According to React-Hooks reference guide, useRef() is useful for more than the ref attribute. It’s handy for keeping any mutable value around similar to how you’d use instance fields in classes.

This is exactly what we need, a stateful value that won't trigger re-render on each state change.

Because our hook is tightly bound to DOM we need to implement it inside an Effect hook. By default, effects run after every completed render, but you can choose to fire it only when certain values have changed.

React currently supports two types of Effect hooks: useEffect and useLayoutEffect.

In our case, the best choice would be useLayoutEffect, it runs synchronously immediately after React has performed all DOM mutations. This can be useful if you need to make DOM measurements (like getting the scroll position or other styles for an element) and then make DOM mutations or trigger a synchronous re-render by updating the state.

As far as scheduling, this works the same way as componentDidMount and componentDidUpdate. Your code runs immediately after the DOM has been updated, but before the browser has had a chance to "paint" those changes (the user doesn't actually see the updates until after the browser has repainted).

If you take a look at the hook's function you'll notice the deps parameter.
We will use it to pass an array of custom dependencies into our hook, forcing it to re-render on their state change and preventing any unnecessary renders.

const [hideOnScroll, setHideOnScroll] = useState(true)

useScrollPosition(({ prevPos, currPos }) => {
  const isShow = currPos.y > prevPos.y
  if (isShow !== hideOnScroll) setHideOnScroll(isShow)
}, [hideOnScroll])

For example, here we start to track the scroll position with the useScrollPosition hook, it will return prevPos and currPos respectively on each position change and will re-render itself on hideOnScroll change, we need this, because hideOnScroll is a stateful variable, which will trigger component re-render on its change triggering the useScrollPosition cleanup routine (componentWillUnmount).

useLayoutEffect(() => {
window.addEventListener('scroll', handleScroll)

return () => window.removeEventListener('scroll', handleScroll)
}, deps)

So, here we have an effect that starts the event listener on componentDidMount and removes it on componentWillUnmount and restarts itself only if any of the deps states have been changed.

Finally, let's take a look into our handler, it'll run every time the scroll position is changed.

Note, that tracking the current scroll position could be extremely tasking, and create an unnecessary load on your application, reducing the user experience dramatically!

This means we need to find a way to optimize this routine as good as we can!
According to this MDN article we can use requestAnimationFrame to optimize our function, in fact this pattern is very often used/copied, although it makes little till no sense in practice, and it's thoroughly explained and discussed on stackoverflow, so I won't dive into it, though The main conclusion is that the good old setTimeout() is what the doctor ordered to throttle the scroll event.

export function useScrollPosition(effect, deps, element, useWindow, wait) {

  let throttleTimeout = null

  const callBack = () => {
    ...
    throttleTimeout = null
  }

  useLayoutEffect(() => {
    const handleScroll = () => {
      if (wait) {
        if (throttleTimeout === null) {
          throttleTimeout = setTimeout(callBack, wait)
        }
      } else {
        callBack()
      }
    }

    ...
  }, deps)
}

Here, the wait parameter is a time period in ms, by which we want, to throttle our function. This mean, that scroll event will update position value, and call the callback only after the waiting period is over.

I made a production ready module, so you can install it and use what we just learned right away!

GitHub logo n8tb1t / use-scroll-position

Use scroll position ReactJS hook done right

use-scroll-position

Node version Node version Node version

Screenshot

use-scroll-position is a React hook that returns the browser viewport X and Y scroll position. It is highly optimized and using the special technics to avoid unnecessary rerenders!

It uses the default react hooks rendering lifecycle, which allows you to fully control its behavior and prevent unnecessary renders.

Demo

Edit use-scroll-position

Install

yarn add @n8tb1t/use-scroll-position

Usage

useScrollPosition(effect,deps, element, useWindow, wait)
Arguments Description
effect Effect callback.
deps For effects to fire on selected dependencies change.
element Get scroll position for a specified element by reference.
useWindow Use window.scroll instead of document.body.getBoundingClientRect() to detect scroll position.
wait The timeout in ms. Good for performance.

The useScrollPosition returns prevPos and currPos.

Examples

Log current scroll position

import { useScrollPosition } from '@n8tb1t
/use-scroll-position'
useScrollPosition(({ prevPos, currPos }) => {
  console

Posted on by:

n8tb1t profile

n8tb1t

@n8tb1t

I'm an open source developer and consultant.

Discussion

pic
Editor guide
 

Hi n8tb1t! I just stumbled upon this article after creating something similar. My hook returns information about scrolling like speed, direction, time scrolling, travelled distance and more. It also allows to add onScrollStart and onScrollEnd callback methods.

Curious to hear what you think!

github.com/dejorrit/scroll-data-hook

 

Hi! Could you explain why you don't pass element here and why you use curly braces for useWindow?

const position = useRef(getScrollPosition({ useWindow }))

I'm getting error if I try to do the same. Thanks!

 

Yes, this is great! I love your plugin.

 

Works wonderfully!!!!! Github examples on point!

 

Thanks implemented it, and it works great.