2

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.

smacdav
  • 405
  • 7
  • 16
  • Think you could create a *running* codesandbox the reproduces this issue that we could inspect and debug live? – Drew Reese Nov 24 '21 at 17:49
  • 1
    @DrewReese I'll give it a try, but I'm primarily a back end developer who recently got a React front end dumped in his lap, so I don't really know what I'm doing, TBH. – smacdav Nov 24 '21 at 20:57
  • 1
    @DrewReese Just in case you wondered, I haven't given up on giving you a running codesandbox. I've been working on it off and on; the problem is that I cannot get it to reproduce my issue. The usual fix for scrolling to the top works just fine in the codesandbox no matter what I do to try to break it. – smacdav Dec 14 '21 at 16:49
  • No worries.... if you are able to get one you can always ping (at me) here like you just did. – Drew Reese Dec 14 '21 at 16:54
  • @DrewReese I'm still unable to reproduce my problem in codesandbox, but I have been able to establish that `scrollTo` is not doing its job because `window.scrollY == 0` no matter how much I have scrolled while viewing the previous page. This doesn't happen in my code sandbox, thus my inability to reproduce the issue. Edit: I don't know if this is helpful in any way, but I thought I'd share just in case. – smacdav Jan 13 '22 at 17:07
  • @DrewReese Progress! Please see my edit added to the end of the question above. – smacdav Jan 13 '22 at 18:08
  • Do you have a codesandbox now with the scrollable box that reproduces the issue? – Drew Reese Jan 13 '22 at 19:16
  • 1
    Sadly, no. I'm attempting to figure out what's different between my codesandbox and my application that is causing this difference. In the codesandbox, scrolling the whole page works fine and scrolling just the div does not. Somewhere in all the complexity of the application, something is causing this to render after the "window" or something. – smacdav Jan 13 '22 at 21:52

2 Answers2

0

Maybe I'm wrong but I think you shouldn't scroll a window to top but some of your elements. Maybe this will help: Set textarea scroll from ReactJS state

  • This seemed like a great idea, so I did my best to implement it in the `MyPage` component. I replaced the `useLocation` and `useEffect` code with ``` const mainRef = useRef(null); useLayoutEffect(() => { if(mainRef.current !== null) { mainRef.current.scrollTop = 0; } }); ``` I also added `ref={mainRef}` to the first element of the component. Unfortunately, it didn't help. – smacdav Nov 30 '21 at 20:12
  • I've made some progress finally, and you're right that I need to scroll an element. Please see my edits to the question. – smacdav Jan 13 '22 at 18:10
0

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.

smacdav
  • 405
  • 7
  • 16