React scroll to top on render--the usual answer doesn't seem to be working

Viewed 679

I have a React application with a left-side navigation bar and then pages being rendered on the right. When I view a page on the right and scroll down then use the navigation bar to go to another page, the new page is rendered with the same scroll position as the previous page. I want the new page to always render scrolled all the way to the top.

I have done a lot of reading the last few days and everyone seems to agree that the solution is to use useEffect as follows:

  const location = useLocation();
  useEffect(() => {
    window.scrollTo(0, 0);
  }, [location]);

This makes perfect sense, and I know from debugging that the effect is run whenever the URL changes, as expected. The problem is that debugging seems to reveal that the effect is run before the page is rendered on the screen. (I step through debugging: the left side navigation is drawn, the effect runs, and then the page is drawn. This makes no sense to me because the effect is defined in the component that draws the page and, as I understand it, useEffect should happen after page rendering.)

Here's some typescript code in the hopes that it will help identify my problem:

The sidebar:

interface Props<T extends Stuff> {
  children: React.ReactNode | React.ReactNode[];
  stuff: {
    [key: string]: T;
  };
  title: string;
  type: 'arf' | 'create' | 'edit';
  handleBackClick: () => void;
}

const MySidebar = <T extends Stuff>({
  children,
  stuff,
  title,
  type,
  handleBackClick,
}: Props<T>) => (
  <Box
    width="100%"
    height="100%"
    position="relative"
    zIndex="1"
    overflow="auto"
  >
    <Flex
      as="aside"
      width={['100%', '100%', '300px']}
      left="0px"
      top="0px"
      position="fixed"
      height={{ base: 'auto', md: '100%' }}
      backgroundImage={[
        `url(${BackgroundImage})`,
        `url(${BackgroundImage})`,
        'none',
      ]}
      backgroundColor={['transparent', 'transparent', 'white']}
      padding={{ base: '0px', md: '0px 20px 0px 20px' }}
      flexDir="column"
      zIndex={9}
      overflow="auto"
    >
      <Box
        flexGrow={1}
        display="flex"
        flexDir="column"
        backgroundColor={['transparent', 'transparent', 'white']}
      >
        <Flex
          textAlign="center"
          my={['30px', '30px', '75px']}
          justifyContent="center"
          minHeight="31px"
        >
          <Box display={['none', 'none', 'block']}>
            <Link to={PrivateRoutes.A_PRIVAE_ROUTE}>
              <Image src={DesktopLogoIcon} alt="Logo" />
            </Link>
          </Box>
        </Flex>
        <Text
          as="p"
          mb="18px"
          fontSize="xs"
          textAlign={{ base: 'center', md: 'left' }}
        >
          <b>{title}</b>
        </Text>
        <Flex
          flexDir={{ base: 'row', md: 'column' }}
          px={{ base: '30px', md: '0px' }}
          as="ul"
          overflowX={['scroll', 'scroll', 'hidden']}
          listStyleType="none"
        >
          {Object.keys(stuff).map((thingName) => {
            const thing = stuff[thingName as keyof typeof stuff] as Stuff;
            return (
              <li key={`${thing.route}-${thing.text}`}>
                <NavLinkWrapperInEntry
                  to={thing.route}
                  activeStyle={
                    type === 'edit'
                      ? {
                          backgroundColor: 'white',
                          color: 'black',
                        }
                      : {}
                  }
                >
                  {thing.completed && type !== 'arf' && (
                    <Image
                      src={CircleCheckIcon}
                      alt="Circle Check"
                      minWidth="16px"
                      mr="6px"
                    />
                  )}
                  {thing.text}
                </NavLinkWrapperInEntry>
              </li>
            );
          })}
        </Flex>
      </Box>
      {type !== 'arf' ? (
        <Button
          buttonColor="black"
          width="100%"
          display={{ base: 'none', md: 'block' }}
          onClick={handleBackClick}
          mb="8px"
        >
          Back to dashboard
        </Button>
      ) : (
        <>
          <Button
            buttonColor="black"
            width="100%"
            display={{ base: 'none', md: 'block' }}
            mb="8px"
            type="submit"
            form="arf_form"
          >
            Save & Exit
          </Button>
          <ButtonLink
            textDecor="underline"
            onClick={handleBackClick}
            mb="20px"
          >
            Back to Dashboard
          </ButtonLink>
        </>
      )}
    </Flex>
    <MyPage type={type}>
      {children}
    </MyPage>
  </Box>
);

export default MySidebar;
type MyPageProps = {
  type: string;
  children: React.ReactNode | React.ReactNode[];
}

const MyPage: React.FC<MyPageProps> = ({ type, children }) => {
  const location = useLocation();
  useEffect(() => {
    window.scrollTo(0, 0);
  }, [location]);

  return (
    <Box
      width={['100%', '100%', 'calc(100% - 300px)']}
      marginLeft={['0px', '0px', '300px']}
      backgroundImage={`url(${BackgroundImage})`}
      padding={{ base: '200px 0px 20px 0px', md: '100px 20px 20px 20px' }}
      as='main'
      display='flex'
      flexDir='column'
      alignItems='center'
      position='relative'
      backgroundRepeat='none'
      backgroundSize='cover'
    >
      {location.pathname !== PrivateRoutes.ARF_ARF && type === 'arf' && (
        <ArfArfArf />
      )}
      <Box maxWidth='900px' width='100%'>
        {children}
      </Box>
    </Box>
  );
}

export default MyPage;

ArfArfArf:

const ArfArfArf: React.FC = () => {
  return (
    <Box
      backgroundColor="black"
      p="15px"
      color="white"
      textAlign="center"
      width="100%"
      maxWidth="900px"
      mb="28px"
    >
      <h2>
        <b>
          Some text we want displayed on almost every page
        </b>
      </h2>
    </Box>
  );
};

export default ArfArfArf;

A typical child being rendered on MyPage is a lengthy form with id="arf_form". I can include a sample if requested, but I don't want to totally overwhelm everyone with code if not necessary.

We do use react-router and my research indicated that instead of putting the useEffect directly in my component I might be better served by creating a ScrollToTop component:

type ScrollToTopProps = {
  children?: React.ReactNode | React.ReactNode[];
}

const ScrollToTop: React.FC<ScrollToTopProps> = ({ children }) => {
  const location = useLocation();
  useEffect(() => {
    window.scrollTo(0, 0);
  }, [location]);

  return <>{children}</>
};

export default ScrollToTop;

I've tried inserting that in various levels of my component tree and it seems to have the exact same effect as putting it where I show it in my code (in MyPage)--the effect runs, but before the page is visually rendered and the page is then rendered at the scroll position of the previous page.

This whole thing has me pretty baffled since virtually every place I've looked has given the same solution and that solution clearly does not work in this case for whatever reason. I'm open to any suggestions.

Edit (2022-01-13):

After much experimentation, I've found that the "window" is not is what's being scrolled--it's the div element represented by the first chakra-ui Box in MySidebar above. I've modified that code to the following:

interface Props<T extends Stuff> {
  children: React.ReactNode | React.ReactNode[];
  stuff: {
    [key: string]: T;
  };
  title: string;
  type: 'arf' | 'create' | 'edit';
  handleBackClick: () => void;
}

const MySidebar = <T extends Stuff>({
  children,
  stuff,
  title,
  type,
  handleBackClick,
}: Props<T>) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  const location = useLocation();

  useEffect(() => {
    if (scrollRef.current !== null) {
      scrollRef.current.scrollTo(0, 0);
    }
  }, [location.pathname]);

  return (
    <Box
      width="100%"
      height="100%"
      position="relative"
      zIndex="1"
      overflow="auto"
      ref={scrollRef}
    >
      ...
    </Box>
  );
});

This sort-of works. The reason I say sort of is that the first time I navigate to any page, scrollRef.current.scrollTop === 0, regardless of the current scroll position, so it won't scroll to the top. Any subsequent load of that page works as expected, however, detecting the scroll position and moving it to the top. So now my question is how to detect that the page element is not actually scrolled to the top when the ref claims that it is for some reason.

Edit (2022-01-14):

I found a workaround, thought it's not really a solution since there is a small chance it won't work on each page load. It turns out that after a few milliseconds, everything is loaded and the ref is aware of its real scroll position. A short timeout of 50 ms is enough to make it work nearly 100% of the time. (Okay, I haven't seen it not work in all my testing, but I know the chance is there. At 20 ms it fails semi-regularly.)

interface Props<T extends Stuff> {
  children: React.ReactNode | React.ReactNode[];
  stuff: {
    [key: string]: T;
  };
  title: string;
  type: 'arf' | 'create' | 'edit';
  handleBackClick: () => void;
}

const MySidebar = <T extends Stuff>({
  children,
  stuff,
  title,
  type,
  handleBackClick,
}: Props<T>) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  const location = useLocation();

  useEffect(() => {
    setTimeout(() => {
      if (scrollRef.current !== null) {
        scrollRef.current.scrollTo(0, 0);
      }
    ), 50);
  }, [location.pathname]);

  return (
    <Box
      width="100%"
      height="100%"
      position="relative"
      zIndex="1"
      overflow="auto"
      ref={scrollRef}
    >
      ...
    </Box>
  );
});

You might be concerned with screen flicker since it technically draws the screen and then, 50ms later, scrolls it. I've been watching for a visible flicker and haven't seen on at 50ms. At higher values, such as 100ms, there is a visible flicker. There are other things going on on the screen in my app when a page is loading which may be making detecting the flicker difficult, but I figure if I'm watching for it and can't see it then my users are highly unlikely to see it.

I am not adding this as an answer because it's a workaround and not a 100% reliable solution. If anyone has a real solution, I would greatly appreciate knowing what it is.

2 Answers

The issue turned out to be a combination of needing to scroll a component instead of the entire page and that component being lazily loaded. The lazy loading explains the delay in knowing the value of scrollTop as described in my update on 2022-01-14. Removing the lazy loading for the components (which really wasn't necessary in the first place) eliminated the delay, and therefore my need to use a timeout before scrolling the component.

Related