Recently our team at the PWA Store decided to upgrade our header to a hiding header. A hiding header allows for more screen space on mobile and over all makes our react app feel more responsive. This is a huge bonus if your app has a lot of data to show, but minimal space to show it.
Third Party Solutions
The PWA Store was created with React Ionic. Ionic does a lot of the heavy lifting for the developer. And wouldn't you know it, their own documentation already has a hiding header on it. So adding that functionality should be ez pz, right? Wrong.
After diving deep into the header component documentation it was clear that hiding the header was not possible through Ionic. There is a function called collapse
, but this only works on iOS. Also, it just hides to reveal another smaller header.
Our second thought was to search npm for something that was already implemented. We ran across React Headroom and it seemed to be everything we were looking for and just an npm install
away.
After installing and adding it to our app, Headroom was broken. It didn't work nicely with our app. Actually it didn't work at all. Bummer.
Build a HidingHeader
Component
At this point we realized it was time to create it on our own. Since the header would be on many of the app listing pages it would need to be reusable. The first idea was to make a HidingHeader
component. The logic for checking the scroll distance of the content
would reside inside the component making adding the header to a page a simple import.
<HidingHeader scrollY={scrollY} />
But this created too many unnecessary rerenders to the DOM as every change in the scroll y position
of the content
was causing the HidingHeader
component to rerender. The only time that the HidingHeader
needs to update is when its position should change. So how do we hold that state and only update the header when it is actually needed?
Introducing the useHidingHeader
Hook 👏👏👏
const [hideDecimal, setScrollY] = useHidingHeader(threshold: number)
useHidingHeader
hook updates a decimal value called hideDecimal
between 0-1 to let the HidingHeader
component know how much of the header should be hidden. 0 means not hidden at all and 1 fully hidden. Our page's content
component sends a callback when scrolling in the y direction updates. This value is then set in the setScrollY(position: number)
state. Finally we pass a threshold
value into the hook to tell it how much of a change in scroll it takes to completely hide the header. Handling the state of the Header this way ensures that the HidingHeader
component will not update for state change unless there is an actual change in how it is displayed.
HidingHeader.tsx
import { IonHeader, IonToolbar } from "@ionic/react"
import React, { memo, useMemo, useRef } from "react"
interface ContainerProps {
children: any
// number between 0 and 1
hideDecimal: number
}
const HidingHeader: React.FC<ContainerProps> = ({ hideDecimal, children }) => {
const header = useRef<any>(null)
const styles = useMemo(
() => ({
marginTop: `${-hideDecimal * 100}px`,
marginBottom: `${hideDecimal * 100}px`,
}),
[hideDecimal]
)
return useMemo(
() => (
<IonHeader
ref={header}
style={styles}
className="ion-no-border bottom-line-border"
>
<IonToolbar>{children}</IonToolbar>
</IonHeader>
),
[children, styles]
)
}
export default memo(HidingHeader)
We update the margins of our Header component when the hideDecimal
changes. This moves the Header up and away from view in the window.
useHidingHeader.ts
import { useState, useEffect } from "react"
type NumberDispatchType = (
threshold: number
) => [number, React.Dispatch<React.SetStateAction<number>>]
export const useHidingHeader: NumberDispatchType = (threshold: number) => {
const [initialChange, setInitialChange] = useState<number>(0)
const [scrollYCurrent, setScrollYCurrent] = useState<number>(0)
// number between 0 and 1
const [hideDecimal, setHideDecimal] = useState<number>(0)
const [scrollYPrevious, setScrollYPrevious] = useState<number>(0)
useEffect(() => {
// at the top or scrolled backwards => reset
if (scrollYCurrent <= 0 || scrollYPrevious > scrollYCurrent) {
setHideDecimal(0)
setInitialChange(scrollYCurrent)
} else {
if (scrollYCurrent > initialChange) {
// start hiding
if (scrollYCurrent < initialChange + threshold)
setHideDecimal((scrollYCurrent - initialChange) / threshold)
// fulling hidden
else if (hideDecimal !== 1) setHideDecimal(1)
}
}
setScrollYPrevious(scrollYCurrent)
}, [scrollYCurrent])
return [hideDecimal, setScrollYCurrent]
}
Typing the Hook
type NumberDispatchType = (
threshold: number
) => [number, React.Dispatch<React.SetStateAction<number>>]
One of the most annoying, but rewarding parts of using Typescript is typing your objects. So in this instance, how do you type a hook? First we must understand what our hook really is.
useHidingHeader
takes in a number and returns an array. The array's order is important, so we must take that into consideration when typing. Inside our array we have a number
and the setter. The setter is a dispatch function defined inside the body of our hook. This setter is actually a React Dispatch that dispatches an action for setting a useState
's value.
The Logic
// at the top or scrolled backwards => reset
if (scrollYCurrent <= 0 || scrollYPrevious > scrollYCurrent) {
setHideDecimal(0)
setInitialChange(scrollYCurrent)
} else {
if (scrollYCurrent > initialChange) {
// start hiding
if (scrollYCurrent < initialChange + threshold)
setHideDecimal((scrollYCurrent - initialChange) / threshold)
// fulling hidden
else if (hideDecimal !== 1) setHideDecimal(1)
}
}
setScrollYPrevious(scrollYCurrent)
The actual logic behind the hook can be found within the useEffect
. We must store the initialChange
value of the scroll. This is the value that the scroll y is compared to. Next, we need to store the scrollYPrevious
value of the scroll. This is the value that the scroll bar was at the previous time the scroll was updated.
Every time scrollYCurrent
is set we execute the function in the useEffect
.
If the scroll bar is at the top or its value is less than the previous value we reset the header's position by updating hideDecimal
to 0.
When scrolling down two things can happen: we are in between the initialChange
value and the threshold
or we have passed that state and are continuing to scroll down.
Usage
const Home: React.FC = () => {
const [hideDecimal, setScrollYCurrent] = useHidingHeader(50)
return (
<IonPage>
<HidingHeader hideDecimal={hideDecimal}>
<div className="HomeHeader">
<div>
<h1>PWA Store</h1>
<IonNote>Progressive Web App Discovery</IonNote>
</div>
</div>
</HidingHeader>
<IonContent
fullscreen={true}
scrollEvents={true}
onIonScroll={(e) => setScrollYCurrent(e.detail.scrollTop)}
>
<div>
Things and stuff.
</div>
</IonContent>
</IonPage>
)
}
Wrapping Up
When some state changes every frame, it can be very beneficial to update the side effects to that change only when necessary. This limits the amount of rerenders to the DOM and our application's overall performance. By using a hook to control the state of our header's margins, we are able to update our header only when it really matters.
Here we see the DOM update only happening when the header is changing its size.
Thanks for reading and please let me know if you can come up with an even better way to do this!
Top comments (1)
Great and simple article, thanks!