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.