Search code examples
javascriptreactjslodashreact-hooks

How to correctly wait on state to update/render instead of using a delay/timeout function?


I will attempt to keep this brief, but I am not 100% sure of the correct method of achieving what I am aiming for. I have been thrown in the deep end with React with not much training, so I have most likely been going about most of this component incorrectly, a point in the right direction will definitely help, I don't really expect for someone to completely redo my component for me as it's quite long.

I have a navigation bar SubNav, that finds the currently active item based upon the url/path, this will then move an underline element that inherits the width of the active element. To do this, I find the position of the active item and position accordingly. The same goes for when a user hovers over another navigation item, or when the window resizes it adjusts the position accordingly.

I also have it when at lower resolutions, when the nav gets cut off to have arrows appear to scroll left/right on the navigation to view all navigation items.

Also, if on a lower resolution and the currently active navigation item is off screen, the navigation will scroll to that item and then position the underline correctly.

This, currently works as I have it in my component, this issue is, I don't believe I have done this correctly, I am using a lodash function delay to delay at certain points (I guess to get the correct position of certain navigation items, as it isn't correct at the time of the functions call), which I feel is not the way to go. This is all based on how fast the page loads etc and will not be the same for each user.

_.delay(
        () => {
          setSizes(getSizes()),
            updateRightArrow(findItemInView(elsRef.length - 1)),
            updateLeftArrow(findItemInView(0));
        },
        400,
        setArrowStyle(styling)
      );

Without using the delay, the values coming back from my state are incorrect as they haven't been set yet.

My question is, how do I go about this correctly? I know my code below is a bit of a read but I have provided a CODESANBOX to play about with.

I have 3 main functions, that all sort of rely on one another:

  1. getPostion()
    • This function finds the active navigation item, checks if it's within the viewport, if it is not, then it changes the left position of the navigation so it's the leftmost navigation item on the screen, and via setSizes(getSizes()) moves the underline directly underneath.
  2. getSizes()
    • This is called as an argument within setSizes to update the sizes state, which returns the left and right boundaries of all navigation items
  3. getUnderlineStyle()
    • This is called as an argument within setUnderLineStyle within the getSizes() function to update the position of the underline object in relation to the position of active navigation item grabbed from the sizes state, but I have to pass the sizesObj as an argument in setSizes as the state has not been set. I think this is where my confusion began, I think I was under the impression, that when I set the state, I could then access it. So, I started using delay to combat.

Below is my whole Component, but can be seen working in CODESANBOX

import React, { useEffect, useState, useRef } from "react";
import _ from "lodash";
import { Link, Route } from "react-router-dom";
import "../../scss/partials/_subnav.scss";

const SubNav = props => {
  const subNavLinks = [
    {
      section: "Link One",
      path: "link1"
    },
    {
      section: "Link Two",
      path: "link2"
    },
    {
      section: "Link Three",
      path: "link3"
    },
    {
      section: "Link Four",
      path: "link4"
    },
    {
      section: "Link Five",
      path: "link5"
    },
    {
      section: "Link Six",
      path: "link6"
    },
    {
      section: "Link Seven",
      path: "link7"
    },
    {
      section: "Link Eight",
      path: "link8"
    }
  ];

  const currentPath =
    props.location.pathname === "/"
      ? "link1"
      : props.location.pathname.replace(/\//g, "");

  const [useArrows, setUseArrows] = useState(false);
  const [rightArrow, updateRightArrow] = useState(false);
  const [leftArrow, updateLeftArrow] = useState(false);

  const [sizes, setSizes] = useState({});

  const [underLineStyle, setUnderLineStyle] = useState({});
  const [arrowStyle, setArrowStyle] = useState({});

  const [activePath, setActivePath] = useState(currentPath);

  const subNavRef = useRef("");
  const subNavListRef = useRef("");
  const arrowRightRef = useRef("");
  const arrowLeftRef = useRef("");
  let elsRef = Array.from({ length: subNavLinks.length }, () => useRef(null));

  useEffect(
    () => {
      const reposition = getPosition();
      subNavArrows(window.innerWidth);
      if (!reposition) {
        setSizes(getSizes());
      }
      window.addEventListener(
        "resize",
        _.debounce(() => subNavArrows(window.innerWidth))
      );
      window.addEventListener("resize", () => setSizes(getSizes()));
    },
    [props]
  );

  const getPosition = () => {
    const activeItem = findActiveItem();
    const itemHidden = findItemInView(activeItem);
    if (itemHidden) {
      const activeItemBounds = elsRef[
        activeItem
      ].current.getBoundingClientRect();
      const currentPos = subNavListRef.current.getBoundingClientRect().left;
      const arrowWidth =
        arrowLeftRef.current !== "" && arrowLeftRef.current !== null
          ? arrowLeftRef.current.getBoundingClientRect().width
          : arrowRightRef.current !== "" && arrowRightRef.current !== null
          ? arrowRightRef.current.getBoundingClientRect().width
          : 30;

      const activeItemPos =
        activeItemBounds.left * -1 + arrowWidth + currentPos;

      const styling = {
        left: `${activeItemPos}px`
      };

      _.delay(
        () => {
          setSizes(getSizes()),
            updateRightArrow(findItemInView(elsRef.length - 1)),
            updateLeftArrow(findItemInView(0));
        },
        400,
        setArrowStyle(styling)
      );

      return true;
    }

    return false;
  };

  const findActiveItem = () => {
    let activeItem;
    subNavLinks.map((i, index) => {
      const pathname = i.path;
      if (pathname === currentPath) {
        activeItem = index;
        return true;
      }
      return false;
    });

    return activeItem;
  };

  const getSizes = () => {
    const rootBounds = subNavRef.current.getBoundingClientRect();

    const sizesObj = {};

    Object.keys(elsRef).forEach(key => {
      const item = subNavLinks[key].path;
      const el = elsRef[key];
      const bounds = el.current.getBoundingClientRect();

      const left = bounds.left - rootBounds.left;
      const right = rootBounds.right - bounds.right;

      sizesObj[item] = { left, right };
    });

    setUnderLineStyle(getUnderlineStyle(sizesObj));

    return sizesObj;
  };

  const getUnderlineStyle = (sizesObj, active) => {
    sizesObj = sizesObj.length === 0 ? sizes : sizesObj;
    active = active ? active : currentPath;

    if (active == null || Object.keys(sizesObj).length === 0) {
      return { left: "0", right: "100%" };
    }

    const size = sizesObj[active];

    const styling = {
      left: `${size.left}px`,
      right: `${size.right}px`,
      transition: `left 300ms, right 300ms`
    };

    return styling;
  };

  const subNavArrows = windowWidth => {
    let totalSize = sizeOfList();

    _.delay(
      () => {
        updateRightArrow(findItemInView(elsRef.length - 1)),
          updateLeftArrow(findItemInView(0));
      },
      300,
      setUseArrows(totalSize > windowWidth)
    );
  };

  const sizeOfList = () => {
    let totalSize = 0;

    Object.keys(elsRef).forEach(key => {
      const el = elsRef[key];
      const bounds = el.current.getBoundingClientRect();

      const width = bounds.width;

      totalSize = totalSize + width;
    });

    return totalSize;
  };

  const onHover = active => {
    setUnderLineStyle(getUnderlineStyle(sizes, active));
    setActivePath(active);
  };

  const onHoverEnd = () => {
    setUnderLineStyle(getUnderlineStyle(sizes, currentPath));
    setActivePath(currentPath);
  };

  const scrollRight = () => {
    const currentPos = subNavListRef.current.getBoundingClientRect().left;
    const arrowWidth = arrowRightRef.current.getBoundingClientRect().width;
    const subNavOffsetWidth = subNavRef.current.clientWidth;

    let nextElPos;
    for (let i = 0; i < elsRef.length; i++) {
      const bounds = elsRef[i].current.getBoundingClientRect();
      if (bounds.right > subNavOffsetWidth) {
        nextElPos = bounds.left * -1 + arrowWidth + currentPos;
        break;
      }
    }

    const styling = {
      left: `${nextElPos}px`
    };

    _.delay(
      () => {
        setSizes(getSizes()),
          updateRightArrow(findItemInView(elsRef.length - 1)),
          updateLeftArrow(findItemInView(0));
      },
      500,
      setArrowStyle(styling)
    );
  };

  const scrollLeft = () => {
    const windowWidth = window.innerWidth;
    // const lastItemInView = findLastItemInView();
    const firstItemInView = findFirstItemInView();
    let totalWidth = 0;
    const hiddenEls = elsRef
      .slice(0)
      .reverse()
      .filter((el, index) => {
        const actualPos = elsRef.length - 1 - index;
        if (actualPos >= firstItemInView) return false;
        const elWidth = el.current.getBoundingClientRect().width;
        const combinedWidth = elWidth + totalWidth;
        if (combinedWidth > windowWidth) return false;
        totalWidth = combinedWidth;
        return true;
      });

    const targetEl = hiddenEls[hiddenEls.length - 1];

    const currentPos = subNavListRef.current.getBoundingClientRect().left;
    const arrowWidth = arrowLeftRef.current.getBoundingClientRect().width;
    const isFirstEl =
      targetEl.current.getBoundingClientRect().left * -1 + currentPos === 0;

    const targetElPos = isFirstEl
      ? targetEl.current.getBoundingClientRect().left * -1 + currentPos
      : targetEl.current.getBoundingClientRect().left * -1 +
        arrowWidth +
        currentPos;

    const styling = {
      left: `${targetElPos}px`
    };

    _.delay(
      () => {
        setSizes(getSizes()),
          updateRightArrow(findItemInView(elsRef.length - 1)),
          updateLeftArrow(findItemInView(0));
      },
      500,
      setArrowStyle(styling)
    );
  };

  const findItemInView = pos => {
    const rect = elsRef[pos].current.getBoundingClientRect();

    return !(
      rect.top >= 0 &&
      rect.left >= 0 &&
      rect.bottom <= window.innerHeight &&
      rect.right <= window.innerWidth
    );
  };

  const findLastItemInView = () => {
    let lastItem;
    for (let i = 0; i < elsRef.length; i++) {
      const isInView = !findItemInView(i);
      if (isInView) {
        lastItem = i;
      }
    }
    return lastItem;
  };

  const findFirstItemInView = () => {
    let firstItemInView;
    for (let i = 0; i < elsRef.length; i++) {
      const isInView = !findItemInView(i);
      if (isInView) {
        firstItemInView = i;
        break;
      }
    }
    return firstItemInView;
  };

  return (
    <div
      className={"SubNav" + (useArrows ? " SubNav--scroll" : "")}
      ref={subNavRef}
    >
      <div className="SubNav-content">
        <div className="SubNav-menu">
          <nav className="SubNav-nav" role="navigation">
            <ul ref={subNavListRef} style={arrowStyle}>
              {subNavLinks.map((el, i) => (
                <Route
                  key={i}
                  path="/:section?"
                  render={() => (
                    <li
                      ref={elsRef[i]}
                      onMouseEnter={() => onHover(el.path)}
                      onMouseLeave={() => onHoverEnd()}
                    >
                      <Link
                        className={
                          activePath === el.path
                            ? "SubNav-item SubNav-itemActive"
                            : "SubNav-item"
                        }
                        to={"/" + el.path}
                      >
                        {el.section}
                      </Link>
                    </li>
                  )}
                />
              ))}
            </ul>
          </nav>
        </div>
        <div
          key={"SubNav-underline"}
          className="SubNav-underline"
          style={underLineStyle}
        />
      </div>
      {leftArrow ? (
        <div
          className="SubNav-arrowLeft"
          ref={arrowLeftRef}
          onClick={scrollLeft}
        />
      ) : null}
      {rightArrow ? (
        <div
          className="SubNav-arrowRight"
          ref={arrowRightRef}
          onClick={scrollRight}
        />
      ) : null}
    </div>
  );
};

export default SubNav;


Solution

  • I think the reason of your delay is necessary here since you calculate based on rectangles of the first and the last element which are affected when you click on button and do animation of scrolling 500ms. So as a result your calculation needs to wait for animation to be done. change the number of animation and delay you will see the relation.

    the style I meant.

    @include transition(all 500ms ease);
    

    In short, I think what you are using is the right way as long as you have animations related to the calculation.