How to get correct navigation state for prev/next methods for custom React-Waypoint hook?

At the moment, I’m creating two hooks for the React-Waypoint component.

What is React Waypoint?

React-Waypoint lets you monitor the scroll position of your browser and once it hits the waypoint it fires an “enter” event, once it is leaving the screen it fires the “leave” event.

useWaypoint hook

This hook can be used to to create waypoints.

Usage example

const [Waypoint, { scrollToWaypoint }] = useWaypoint({
    component: <div>If I'm visible I will trigger an event!</div>,
    enter: () => console.log("waypoint enter"),
    leave: () => console.log("waypoint leave")
  });

return (
    {/* other content */}
    <Waypoint/>
)

With this, it will trigger the enter/leave events once the waypoint div gets visible/invisible.
The method scrollToWaypoint can be used to scroll to the element.

useWaypointNavigation hook

The idea is to have a navigation between waypoints. With previous and next buttons (see below screenshot).

This is where I’m having the following issues:

  • Fast scrolling creates an inconsistent state but not sure why (this can be fixed later)
  • The main issue is that the first and last prev/next navigation is not working if scrolled manually before clicking.

Scrolling issue

Scroll the screen so that “w0” is visible (like in the below screenshot), then click next. The expected behavior is to scroll “w0” into view but it scrolls to “w1”.
w0 scroll position

Same issue at the last position. Scroll below “w3” and hit prev. The expected behavior is to scroll “w3” into view but it scrolls to “w2”.

w3 scroll position

Maybe it’s just logic that’s missing but I’m struggling with it for some time now and I’m not sure how to fix this.

How does the hook work?

First, the useWaypoint hooks are initialized in an array

const waypoints: Array<UseWaypointReturn> = [
    useWaypoint({
      component: <Box index="w0" />,
      enter: enter("w0"),
      leave: leave("w0")
    }),
    useWaypoint({
      component: <Box index="w1" />,
      enter: enter("w1"),
      leave: leave("w1")
    }),
    useWaypoint({
      component: <Box index="w2" />,
      enter: enter("w2"),
      leave: leave("w2")
    }),
    useWaypoint({
      component: <Box index="w3" />,
      enter: enter("w3"),
      leave: leave("w3")
    })
  ];

And then passed to the hook, so the hook get the information about each waypoint.

  const {
    visibilityMap,
    nextWaypointIndex,
    prevWaypointIndex,
    next,
    prev
  } = useWaypointNavigation({ waypoints });

The return values from the hook are some debug information and the next and prev method that scroll to the next or prev. waypoint.

visibilityMap is used to determine the prev / next index. The index is the position in the waypoints array, so we can use the scrollToWaypoint method.

The logic of the prev/next index calucalation is inside the useEffect:

  1. Iterate through the visibilityMap e.g. [false,false,true,false] to determine the last visible index lastVisibleIndex and the visibleCount
  2. Two cases here:
  • If one item is visible, we can use the lastVisibleIndex to calculate the prev & next index
  • If two items are visible, we’re taking the lastVisibleIndex as the next index as we’re between two waypoints –> could be problematic for 3 visible but it’s working
  1. Special case: Scrolled below all waypoints should hold the last waypoint. With-out holding it was resetting prev and next index to zero. Holding works but there is something missing to get the expected behavior.

Any ideas are highly appreciated. Would be great if I’m just not seeing the cause and there is an easy fix.

Things I’ve tried:

  • onPositionChange or onEnter / onLeave are containing information about the position but I couldn’t get it to work (maybe I have to check this again). Position information is “inside”, “above”, “below” but the previousPosition wasn’t helping (most of the time it was undefined)
  • Tried to add more logic around lastVisibleIndex or visibibleCount but without success

Source code

You can find the code in the following Codesandbox and below.

useWaypoint hook

import React, {
  FC,
  MutableRefObject,
  ReactNode,
  useRef,
  useState
} from "react";

import { Waypoint } from "react-waypoint";

type useWaypointProps = {
  enter?: () => void;
  leave?: () => void;
  component?: ReactNode;
};

export type UseWaypointReturn = [
  FC<any>,
  {
    ref: MutableRefObject<ReactNode>;
    isVisible: boolean; ///> true = waypoint entered & not left yet
    scrollToWaypoint: () => void;
  }
];

interface IUseWaypoint {
  (props: useWaypointProps): UseWaypointReturn;
}

export const useWaypoint: IUseWaypoint = ({
  component,
  enter,
  leave,
  ...hookProps
}) => {
  const waypointRef = useRef<HTMLDivElement | null>(null);
  const [isVisible, setVisible] = useState(false);

  const scrollToWaypoint = () => {
    if (waypointRef && waypointRef.current) {
      waypointRef.current.scrollIntoView({
        block: "start",
        behavior: "smooth"
      });
    }
  };

  const onEnter = () => {
    setVisible(true);
    if (enter) {
      enter();
    }
  };

  const onLeave = () => {
    setVisible(false);
    if (leave) {
      leave();
    }
  };

  return [
    ({ children, ...props }) => (
      <div ref={waypointRef}>
        <Waypoint
          debug={false}
          {...props}
          {...hookProps}
          onEnter={onEnter}
          onLeave={onLeave}
        >
          {component ? <div>{component}</div> : <div>{children}</div>}
        </Waypoint>
      </div>
    ),
    {
      ref: waypointRef,
      isVisible,
      scrollToWaypoint
    }
  ];
};

useWaypointNavigigation hook

import React, { FC, useEffect, useState } from "react";
import "./styles.css";
import { useWaypoint, UseWaypointReturn } from "./hooks/useWaypoint";

// Issues:
// - Fast scrolling missing leave events? --> inconsistent navigation state
//   --> check "rapid fire" prop on Waypoint component - should be active by default
//
// Todo:
// - Add prev - next waypoint - requires a check which waypoints are on screen
//   --> create hook useWaypointNavigation

type useWaypointNavigationProps = {
  waypoints: Array<UseWaypointReturn>;
};

type UseWaypointNavigationReturn = {
  next: () => void;
  prev: () => void;
  nextWaypointIndex: number;
  prevWaypointIndex: number;
  visibilityMap: Array<Boolean>;
};

interface IUseWaypointNavigation {
  (props: useWaypointNavigationProps): UseWaypointNavigationReturn;
}

const createVisiblityMap = (
  waypoints: Array<UseWaypointReturn>
): Array<Boolean> => waypoints.map((w: any) => w[1].isVisible);

const useWaypointNavigation: IUseWaypointNavigation = ({ waypoints }) => {
  const visibilityMap = createVisiblityMap(waypoints);
  const [holdLastWaypoint, setHoldLastWaypoint] = useState(false);
  const [nextWaypointIndex, setNext] = useState(0);
  const [prevWaypointIndex, setPrev] = useState(0);

  /*
  // not needed as we're calculating prev/next from waypoints
  // (were used for testing prev/next scrolling)
  // --> can be tested if useEffect block is commented
  const incrementWaypointIndex = () => {
    const lastWaypointIndex = waypoints.length - 1;
    if (nextWaypointIndex + 1 < lastWaypointIndex) {
      setPrev(nextWaypointIndex);
      setNext(nextWaypointIndex + 1);
    } else {
      setPrev(lastWaypointIndex - 1);
      setNext(lastWaypointIndex);
    }
  };

  const decrementWaypointIndex = () => {
    if (prevWaypointIndex - 1 >= 0) {
      setPrev(prevWaypointIndex - 1);
      setNext(prevWaypointIndex);
    } else {
      setPrev(0);
      setNext(0);
    }
  };*/

  useEffect(() => {
    console.log("update next / prev index");
    const mapLength = visibilityMap.length;
    // "scroll fast issue" --> why?
    // Visibility map inconsistent and can't be used to detect
    // position

    // true = one waypoint in view
    // true, true = two waypoints in view --> next = last true
    // special case all false --> above or below
    // -- waypoints "below" handled by visibility count + start values
    // -- waypoints "above" handled by holding last prev/next by disabling updating
    let lastVisibleIndex = 0;
    let visibleCount = 0;
    visibilityMap.forEach((val, index) => {
      if (val) {
        visibleCount++;
        lastVisibleIndex = index;

        if (mapLength - 1 === index) {
          // last element visible --> hold setting
          setHoldLastWaypoint(true);
        } else {
          setHoldLastWaypoint(false);
        }
      }
    });

    let next;

    if (visibleCount === 1) {
      // only one element in view -> +1 to get next waypoint index
      next =
        lastVisibleIndex + 1 >= mapLength
          ? mapLength - 1
          : lastVisibleIndex + 1;
    } else {
      // one or more items visible -> last element in view = next element
      next = lastVisibleIndex;
    }

    if (!holdLastWaypoint) {
      // only update if not holding last waypoint
      // --> holding needed if waypoints are scrolled out of view
      //     so they're above and we'd like to navigate back
      setPrev(lastVisibleIndex > 1 ? lastVisibleIndex - 1 : 0);
      setNext(visibleCount === 0 ? 0 : next);
    }
  }, [
    visibilityMap,
    holdLastWaypoint,
    setHoldLastWaypoint,
    setNext,
    setPrev,
    nextWaypointIndex,
    prevWaypointIndex
  ]);

  return {
    next: () => {
      waypoints[nextWaypointIndex][1].scrollToWaypoint();
    },
    prev: () => {
      waypoints[prevWaypointIndex][1].scrollToWaypoint();
    },
    nextWaypointIndex, // just for debugging
    prevWaypointIndex,
    visibilityMap
  };
};

type BoxProps = {
  index?: string | number | null;
};

const Box: FC<BoxProps> = ({ index }) => (
  <div className="box">
    <h1>{index}</h1>
  </div>
);

const Spacer: FC = () => (
  <div style={{ height: "1000px", border: "1px solid red" }}></div>
);

export default function App() {
  const enter = (index: number | string) => () => {
    console.log(`waypoint ${index} enter`);
  };

  const leave = (index: number | string) => () => {
    console.log(`waypoint ${index} leave`);
  };

  /*
  Re-add usage demo later
  const [Waypoint0, { scrollToWaypoint: scrollWay0 }] = useWaypoint({
    component: <Box index="0" />,
    enter: enter(0),
    leave: leave(0)
  });

  const [Waypoint1] = useWaypoint({
    //component: <Box index="0" />,
    // added as child to Waypoint1
    enter: enter(1),
    leave: leave(1)
  });

  const [WaypointInvisble] = useWaypoint({
    //component: <Box index="0" />,
    // with-out component or children to have a invisible waypoint
    enter: enter("invisible"),
    leave: leave("invisible")
  });
  */

  const waypoints: Array<UseWaypointReturn> = [
    useWaypoint({
      component: <Box index="w0" />,
      enter: enter("w0"),
      leave: leave("w0")
    }),
    useWaypoint({
      component: <Box index="w1" />,
      enter: enter("w1"),
      leave: leave("w1")
    }),
    useWaypoint({
      component: <Box index="w2" />,
      enter: enter("w2"),
      leave: leave("w2")
    }),
    useWaypoint({
      component: <Box index="w3" />,
      enter: enter("w3"),
      leave: leave("w3")
    })
  ];

  const {
    visibilityMap,
    nextWaypointIndex,
    prevWaypointIndex,
    next,
    prev
  } = useWaypointNavigation({ waypoints });

  return (
    <div className="App">
      <div className="controls">
        <button onClick={waypoints[0][1].scrollToWaypoint}>
          Scroll to Waypoint w0
        </button>
        <h3>Debug</h3>
        <pre>
          {JSON.stringify(visibilityMap, null, 2)}
          <br />
          next {nextWaypointIndex}
          <br />
          prev {prevWaypointIndex}
          <br />
          <button onClick={prev}>Prev</button>
          <button onClick={next}>Next</button>
        </pre>
      </div>
      <Spacer />
      {/* Re-add the usage demo later
      <Waypoint0 />
      <Waypoint1>
        <h2>Also possible to add the content here</h2>
      </Waypoint1>
      <WaypointInvisble />
      */}

      {/* more waypoints (named w0, w1, w2,...*/}
      {waypoints.map((w, i) => {
        // index 0 = Component,
        // index 1 = {ref, scrollToWaypoint, ...rest}
        const Waypoint = w[0];
        return <Waypoint key={i} />;
      })}
      <Spacer />
    </div>
  );
}