Bohdan Yarema - Blog

Sticky Component in React with hooks

Hello and welcome to another How-To!

Today we will talk about sticky elements for web pages written in React.
By sticky elements I mean something that is displayed on the screen and remains in the same spot when we scroll. We will create one as a functional component using React hooks which can show any other component as a sticky header.
The same principles can also be used for React Native with little modifications of the code.

TL;DR;
You are just here to copy the working solution and paste it in your code? Here you go!

import React, {
    CSSProperties,
    PropsWithChildren,
    useEffect,
    useRef,
    useState } from 'react';

export default function Sticky(props: PropsWithChildren<{}>) {
    const [offset, setOffset] = useState<number | undefined>(undefined);
    const [height, setHeight] = useState<number>(0);
    const [style, setStyle] = useState<CSSProperties>({});
    const elementRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (elementRef.current) {
            const boundingRect = elementRef.current.getBoundingClientRect();
            if (!offset) {
                setOffset(boundingRect.y);
            }

            if (height !== boundingRect.height) {
                setHeight(boundingRect.height);
            }
        }
    });

    useEffect(() => {
        if (offset) {
            setStyle({ position: 'fixed', top: offset, zIndex: 99 });
        }
    }, [offset]);

    return (
        <>
            <div ref={elementRef} style={style}>
                {props.children}
            </div>
            <div style={{ visibility: 'hidden', height }} />
        </>
    );
}

As for the more curious out of you let’s once again retrace the thought process and creation of this component.

Let’s figure out our function signature.
I would like to use out component like this:

return (
    ...
    <Sticky>
        <h1>
            This is out sticky element!
        </h1>
    </Sticky>
    ...
)

To use it like that we need something that takes children and displays them.

export default function Sticky(props: PropsWithChildren<{}>) {
    return props.children;
}

First, let’s figure out how to display a sticky header using CSS. For that we can use position: 'fixed'. We need an element around children that we can apply styles to.

That leaves us with

export default function Sticky(props: PropsWithChildren<{}>) {
    return (
        <div style={{ position: 'fixed' }}>
            {props.children}
        </div>
    );
}

Well, if you put this component inside of an existing page or put anything else on the same page you’ll notice several issues.
Firstly, the component sticks to the top of the screen.
Secondly, it overlaps with another not fixed component that now also occupies the same spot.
Thirdly, when we scroll our sticky component “dives” under other components.

Let’s fix these issues one by one from easiest to toughest to solve.
For our component not to dive beneath other elements we can use zIndex: 99. 99 here is an arbitrary value. I just picked something high enough to typically stay on top. You can choose higher or lower values as you please.

export default function Sticky(props: PropsWithChildren<{}>) {
    return (
        <div style={{ position: 'fixed', zIndex: 99 }}>
            {props.children}
        </div>
    );
}

Next we shall make out component to stay in the correct spot instead of at the top of the screen. In the styles we can use top for that. But what shall be the value? Well, we can get the vertical offset of the element relative to page top. To get an element from our code we’ll use the first hook - useRef. Then we need to store the offset somewhere and use it in out styles. That’s another hook - useState.
That leaves us with

import React, {
    CSSProperties,
    PropsWithChildren,
    useRef,
    useState } from 'react';

export default function Sticky(props: PropsWithChildren<{}>) {
    const [offset, setOffset] = useState<number | undefined>(undefined);
    const [style, setStyle] = useState<CSSProperties>({});
    const elementRef = useRef<HTMLDivElement>(null);

    return (
        <div ref={elementRef} style={style}>
            {props.children}
        </div>
    );
}

We aren’t doing anything yet though. And our component isn’t even sticky now.
Don’t worry, we are about to change that using the next hook to be introduced - useEffect.

As you might have noticed we defined offset as number | undefined with undefined as initial value. Why is that? Well, we want to use the offset to set the style. We only want to do that when the offset is what we need it to be. Zero can be a valid value, but then you don’t know if zero is initial value or the one we set form our code. So undefined works better and is also semantically more correct.

But let’s set the value then. Do not forget that on first render the ref has a value of null.

import React, {
    CSSProperties,
    PropsWithChildren,
    useEffect,
    useRef,
    useState } from 'react';

export default function Sticky(props: PropsWithChildren<{}>) {
    const [offset, setOffset] = useState<number | undefined>(undefined);
    const [style, setStyle] = useState<CSSProperties>({});
    const elementRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (elementRef.current) {
            if (!offset) {
                const boundingRect = elementRef.current
                    .getBoundingClientRect();
                setOffset(boundingRect.y);
            }
        }
    });

    return (
        <div ref={elementRef} style={style}>
            {props.children}
        </div>
    );
}

This will set the offset if it wasn’t set yet. Which means that this piece of code will only trigger one state change and therefore but a single additional render. Which is of course something we want, as with the growingly complex pages the less render - the less sloweness user will experience.

Now let’s also set the style finally with what we need:

import React, {
    CSSProperties,
    PropsWithChildren,
    useEffect,
    useRef,
    useState } from 'react';

export default function Sticky(props: PropsWithChildren<{}>) {
    const [offset, setOffset] = useState<number | undefined>(undefined);
    const [style, setStyle] = useState<CSSProperties>({});
    const elementRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (elementRef.current) {
            if (!offset) {
                const boundingRect = elementRef.current
                    .getBoundingClientRect();
                setOffset(boundingRect.y);
            }
        }
    });

    useEffect(() => {
        if (offset) {
            setStyle({ position: 'fixed', top: offset, zIndex: 99 });
        }
    }, [offset]);

    return (
        <div ref={elementRef} style={style}>
            {props.children}
        </div>
    );
}

Here we use a useEffect with a guard - [offset]. Well, that’s because we only want to run this effect whenever the offset changes.
This will therefore result in only one additional render as well.

Let’s run this. Now it should remain in the same place when you scroll while adding the minimal number of renders. This leaves us with the last issue to solve - as soon as the style is set the space which was occupied by out component will become vacant and will be filled up by other components.
What we will do to prevent this - create an empty invisible element with the same height as our children and place it there to keep the space occupied.

This brings us to the final code:

import React, {
    CSSProperties,
    PropsWithChildren,
    useEffect,
    useRef,
    useState } from 'react';

export default function Sticky(props: PropsWithChildren<{}>) {
    const [offset, setOffset] = useState<number | undefined>(undefined);
    const [height, setHeight] = useState<number>(0);
    const [style, setStyle] = useState<CSSProperties>({});
    const elementRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        if (elementRef.current) {
            const boundingRect = elementRef.current.getBoundingClientRect();
            if (!offset) {
                setOffset(boundingRect.y);
            }

            if (height !== boundingRect.height) {
                setHeight(boundingRect.height);
            }
        }
    });

    useEffect(() => {
        if (offset) {
            setStyle({ position: 'fixed', top: offset, zIndex: 99 });
        }
    }, [offset]);

    return (
        <>
            <div ref={elementRef} style={style}>
                {props.children}
            </div>
            <div style={{ visibility: 'hidden', height }} />
        </>
    );
}

height !== boundingRect.height once again limits the number of renders that we generate as a result of state changes.
Empty <> tag allows us to nest several components under one while not creating a DOM element. This is a shortcut for <Fragment>.

Thanks for sticking around!
I hope this gave you some ideas at the very least and maybe also was helpful in seeing how some React hooks could be used. See you next time!