react
react hooks
react context
Estimate reading time10min read

A moving header with react hooks and react context

Author
Mario Slepčević
Published on
Jun 05, 2020.
In this article we explore how React Hooks can be used in conjunction with React Context to easily create a scroll based moving header whose visibility is known to every other component on the page.
Author
Mario Slepčević
Published on
Jun 05, 2020.

You’ve probably noticed the way the header on this page moves up and down depending on whether you’re scrolling down or up. If you haven’t, but would like to, try scrolling the page a little bit in both directions

The idea

Since the header movement is scroll based, the first question that came to mind was how to make the page scroll information easily available to every component in the DOM tree, because once we can get our hands on this information it’s fairly easy to make other fun stuff based on it. I’ve already used React Hooks and React Context in other projects, and they seemed like the perfect fit. 

The idea is to:

  1. Create a hook that adds a scroll event handler to the window object in order to obtain and provide the page scroll position.

  2. Create the context that uses this hook to provide scroll information to the whole DOM tree.

  3. Use this context's provider component just in one place since it wouldn't make sense to handle the scroll event with the same handler multiple times.

The hook

Let’s break it down.

First, let’s import some things that we need: import { useEffect, useMemo, useRef, useState } from 'react' import throttle from 'lodash.throttle'

The first import is pretty self-explanatory, while the second one is used to import the lodash throttle function which is used to throttle the scroll handler. Since the scroll event is triggered very very very often, and it wouldn’t make sense to handle each of these events, throttling is introduced. It’s used to control how often a function (in our case the scroll handler) is going to be called (e.g. every 150 milliseconds), which greatly reduces the amount of scroll events that need to be handled. You can read more about throttling and debouncing in this awesome css-tricks article.

Now that importing is out of the way, we can start adding some logic:

const getScrollPosition = () => { if (typeof window === 'undefined') { return 0 } return ( window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0 ) }

This function is used to fetch the current scroll position of the document. Since we’re using SSR (Server-Side Rendering), window and document might not be available for the initial render, in which case the function returns 0.

After we know how to get the scroll position, it’s time to get hooked.

First, let’s define the hook and how the scroll position is going to be stored:

export const useScroll = (wait = 250) => { const defaultScrollTop = useMemo(() => getScrollPosition(), []) const previousScrollTop = useRef(defaultScrollTop) const [currentScrollTop, setCurrentScrollTop] = useState(defaultScrollTop) ...

While knowing the current scroll position is cool, also knowing the previous one is even cooler. For example, it allows for scroll direction detection, and coupled with the time that elapsed between two scroll events we can easily calculate the average scroll velocity. 🏎 🏎 🏎

This is the line that allows previous state memoization:

const previousScrollTop = useRef(defaultScrollTop)

You can look at React Refs in hooks (previousScrollTop.current in this case) just like you would at this.previousScrollTop in a React Class Component. In short, they are a way of storing information without using state. 

Now that we have a place to store the previous and current scroll position, it’s time to implement the actual scroll handling inside the useEffect hook: 

... useEffect(() => { const handleDocumentScroll = () => { const scrollTop = getScrollPosition() setCurrentScrollTop(scrollTop) previousScrollTop.current = scrollTop } const handleDocumentScrollThrottled = throttle(handleDocumentScroll, wait) window.addEventListener('scroll', handleDocumentScrollThrottled) return () => { window.removeEventListener('scroll', handleDocumentScrollThrottled) } }, [wait]) ...

This is pretty straight forward:

  1. Define the scroll handling function

  2. Throttle it

  3. Add the throttled function to the window scroll event handler

  4. Remove the scroll event handler in the useEffect’s return function

All that’s left is to return the data, and we’re ready to use the hook.

... return { scrollTop: currentScrollTop, previousScrollTop: previousScrollTop.current, time: wait, } }

The Context

Now that we have the hook in place, it’s time to use it, so let’s get to it.

First, let’s add some imports and define constants for the scroll throttle wait time and the possible scroll directions:

import React, { createContext, useContext, useMemo } from 'react' import { useScroll } from './use-scroll' const WAIT = 150 export const SCROLL_DIRECTION = { Down: 'down',   None: 'none',   Up: 'up', }

Next, we create the context and provide the default values:

export const ScrollContext = createContext({   scrollTop: 0,   previousScrollTop: 0,   time: WAIT,   amountScrolled: 0,   direction: SCROLL_DIRECTION.None,   velocity: 0, })

After the context is created it’s time to provide it through a Provider Component. Since we’ve been mentioning scroll a lot, what better way to start the component off then by using the hook we just made:

export const ScrollProvider = ({ children }) => {   const { scrollTop, previousScrollTop, time } = useScroll(WAIT)   ...

Now that we have the scroll data, let’s use it to calculate the scrolled amount, direction, and velocity:

...   const amountScrolled = useMemo(() => (     scrollTop - previousScrollTop   ), [scrollTop, previousScrollTop])   const direction = useMemo(() => {     if (amountScrolled < 0) {       return SCROLL_DIRECTION.Up     } else if (amountScrolled > 0) {       return SCROLL_DIRECTION.Down     }     return SCROLL_DIRECTION.None   }, [amountScrolled])   const velocity = useMemo(() => (     Math.abs(amountScrolled / time)   ), [amountScrolled, time])   ...

Finally, let’s pass all the values to the Context.Provider, and wrap the children prop with it. We can consume the values in any part of the DOM tree that our Provider Component is a parent of (we’ll get to that later): 

...   const value = useMemo(() => ({     scrollTop,     previousScrollTop,     time,     amountScrolled,     direction,     velocity,   }), [     scrollTop,     previousScrollTop,     time,     amountScrolled,     direction,     velocity,   ])   return (     <ScrollContext.Provider value={value}>       {children}     </ScrollContext.Provider>   ) }

In the end, let’s also create a convenience hook to consume the context:

export const useScrollContext = () => useContext(ScrollContext)

The last thing we need to do before we can start using this context is wrap our root component with the ScrollProvider. This depends on the way your project is set up, but more or less it looks something like this:

... <ScrollProvider>   <RootComponent/> </ScrollProvider> ... Now every component used inside RootComponent, and the root component itself can use the useScrollContext hook to get the data provided by ScrollProvider.

The Header

Now that we have our scroll handled, we can proceed to the main component, the Header.

First, let's get our imports out of the way:

import React, { createContext, useMemo, useContext, useEffect } from 'react' import { ScrollDirection, useScrollContext } from './ScrollProvider' import { useVisibility } from './use-visibility'

The first two imports should be known to you, but the third one brings in something new, a helper hook used for handling visibility.

Let's quickly take a look at it:

import { useCallback, useState } from 'react' export const useVisibility = (defaultValue = false) => { const [isVisible, setIsVisible] = useState(defaultValue) const show = useCallback(() => { setIsVisible(true) }, []) const hide = useCallback(() => { setIsVisible(false) }, []) const toggle = useCallback(() => { setIsVisible(currentState => !currentState) }, []) return { isVisible, setIsVisible, show, hide, toggle } }

Since we needed to handle visibility for a couple of components in the project, we made this hook. It basically has a boolean state for visibility, and functions to handle it.

We also define one constant which is used to determine how many pixels from the top of the page the show/hide behaviour should start:

const TOP_START = 100

We can now start defining our context which will provide visibility information:

export const HeaderContext = createContext({ isVisible: true, })

Just like with the scroll context, we also need to define our provider component that implements the scroll based visibility logic in the useEffect part. The logic itself is pretty easy to understand, so I won't go into detail.

export const HeaderProvider = ({ children }) => { const { isVisible, show, hide } = useVisibility(true) const { scrollTop, direction } = useScrollContext() useEffect(() => { const shouldShow = scrollTop <= TOP_START || direction !== ScrollDirection.Down const shouldHide = ( scrollTop > TOP_START && direction === ScrollDirection.Down ) if (shouldShow) { show() } else if (shouldHide) { hide() } }, [scrollTop, direction, hide, show]) const value = useMemo(() => ({ isVisible, }), [ isVisible, ]) return ( <HeaderContext.Provider value={value}> {children} </HeaderContext.Provider> ) }

We also provide a convenience hook for easier context consumption:

export const useHeaderContext = () => useContext(HeaderContext)

The next step is to actually provide this context to the rest of our application. All we need to do is wrap the RootComponent with the HeaderProvider component just like we did with the ScrollProvider. The only thing we need to watch out for is that we put it as descendant of the scroll provider, since it uses its context. The result would be:

... <ScrollProvider> <HeaderProvider>    <RootComponent/> </HeaderProvider> </ScrollProvider> ...

Now that our whole DOM tree can easily access the header's visibility, all we need to do is actually show/hide the header through HTML/CSS. Here's a quick example on how this can be done:

import React from 'react' import { useHeaderContext } from './HeaderContext' export const Header = () => { const { isVisible } = useHeaderContext() return ( <header style={{ position: 'fixed', height: '100px', width: '100%', top: 0, left: 0, transform: `${isVisible ? 'translateY(0)' : 'translateY(-100%)'}`, transition: 'transform 0.5s ease-in-out' }}> ... </header> ) }

As you can see, a different transform value is used based on the header visibility, and adding the transition property causes the show/hide animation. This is just a small example, but it shouldn't be hard to apply the same approach whether you're using css classes, css modules, styled-components, or some other way of styling your components.

The End

Well, that's about it from me. I hope you managed to at least get an idea on how hooks and context can be a useful tool in your programming toolbelt.

Until next time, stay hooked!

...or is it

To make it more convenient, I've put all the code from the article in one place.

use-scroll.js

import { useEffect, useMemo, useRef, useState } from 'react' import throttle from 'lodash.throttle' const getScrollPosition = () => {   if (typeof window === 'undefined') {     return 0   }   return (     window.pageYOffset ||     document.documentElement.scrollTop ||     document.body.scrollTop ||     0   ) } export const useScroll = (timeout = 250) => {   const defaultScrollTop = useMemo(() => getScrollPosition(), [])   const previousScrollTop = useRef(defaultScrollTop)   const [currentScrollTop, setCurrentScrollTop] = useState(defaultScrollTop)  useEffect(() => {     const handleDocumentScroll = () => {       const scrollTop = getScrollPosition()       setCurrentScrollTop(scrollTop)       previousScrollTop.current = scrollTop     }     const handleDocumentScrollThrottled = throttle(handleDocumentScroll, timeout)     window.addEventListener('scroll', handleDocumentScrollThrottled)     return () => {       window.removeEventListener('scroll', handleDocumentScrollThrottled)     }   }, [timeout])   return {     scrollTop: currentScrollTop,     previousScrollTop: previousScrollTop.current,     time: timeout,   } }

ScrollProvider.js

import React, { createContext, useContext, useMemo } from 'react' import { useScroll } from './use-scroll' const TIMEOUT = 150 export const SCROLL_DIRECTION = {   Down: 'down',   None: 'none',   Up: 'up', } export const ScrollContext = createContext({   scrollTop: 0,   previousScrollTop: 0,   time: TIMEOUT,   amountScrolled: 0,   direction: SCROLL_DIRECTION.None,   velocity: 0, }) export const ScrollProvider = ({ children }) => {   const { scrollTop, previousScrollTop, time } = useScroll(TIMEOUT)   const amountScrolled = useMemo(() => (     scrollTop - previousScrollTop   ), [scrollTop, previousScrollTop])   const direction = useMemo(() => {     if (amountScrolled < 0) {       return SCROLL_DIRECTION.Up     } else if (amountScrolled > 0) {       return SCROLL_DIRECTION.Down     }     return SCROLL_DIRECTION.None   }, [amountScrolled])   const velocity = useMemo(() => (     Math.abs(amountScrolled / time)   ), [amountScrolled, time])   const value = useMemo(() => ({     scrollTop,     previousScrollTop,     time,     amountScrolled,     direction,     velocity,   }), [     scrollTop,     previousScrollTop,     time,     amountScrolled,     direction,     velocity,   ])   return (    <ScrollContext.Provider value={value}>     {children}    </ScrollContext.Provider>   ) } export const useScrollContext = () => useContext(ScrollContext)

use-visibility.js

import { useCallback, useState } from 'react' export const useVisibility = (defaultValue = false) => { const [isVisible, setIsVisible] = useState(defaultValue) const show = useCallback(() => { setIsVisible(true) }, []) const hide = useCallback(() => { setIsVisible(false) }, []) const toggle = useCallback(() => { setIsVisible(currentState => !currentState) }, []) return { isVisible, setIsVisible, show, hide, toggle } }

HeaderProvider.js

import React, { createContext, useMemo, useContext, useEffect } from 'react' import { ScrollDirection, useScrollContext } from './ScrollProvider' import { useVisibility } from './use-visibility' const TOP_START = 100 // how many px from the top of the page should the show/hide behaviour start export const HeaderContext = createContext({ isVisible: true, }) export const HeaderProvider = ({ children }) => { const { isVisible, show, hide } = useVisibility(true) const { scrollTop, direction, } = useScrollContext() useEffect(() => { const shouldShow = scrollTop <= TOP_START || direction !== ScrollDirection.Down const shouldHide = ( scrollTop > TOP_START && direction === ScrollDirection.Down ) if (shouldShow) { show() } else if (shouldHide) { hide() } }, [scrollTop, direction, hide, show]) const value = useMemo(() => ({ isVisible, }), [ isVisible, ]) return ( <HeaderContext.Provider value={value}> {children} </HeaderContext.Provider> ) } export const useHeaderContext = () => useContext(HeaderContext)

More articles

Background stars
PREVIOUS ARTICLE

Is React the right choice for your next project?

 
NEXT ARTICLE

How to make a portfolio website with animated 3D models using WebGL/Babylon.js and Gatsby.js

Tell us what you need

Section headline decoration