Router: scroll position on navgiate

Created on 14 Feb 2019  路  11Comments  路  Source: reach/router

Hi all,

Whenever I click on Link, i don't see the page jump back to the top. After reading the code, I can't find anywhere mentioning window.scrollTo. So is this not the default behavior? If so, I did the following, is this right?

history.listen(({ action }) => {
  if (action !== 'POP') {
    window.scrollTo(0, 0);
  }
});

...

<LocationProvider history={history}>
      <Router>
...
needs docs

Most helpful comment

I had to use a combination of things to make it work. The scroll to top component will still work with you don't have primary={false}, but it causes jank from where it focuses the route and then window.scrollTo fires.

      <Router primary={false}>
        <ScrollToTop path="/">
          <Home path="/" />
          <Contact path="contact-us" />
          <ThankYou path="thank-you" />
          <WhoWeAre path="who-we-are" />
        </ScrollToTop>
      </Router>

This is based off of React Router's scroll restoration guide https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/docs/guides/scroll-restoration.md

// ScrollToTop.js
import React from 'react'

export const ScrollToTop = ({ children, location }) => {
  React.useEffect(() => window.scrollTo(0, 0), [location.pathname])
  return children
}

All 11 comments

I had the same issue. This is because the reach router automatically manages focus for you. To disable this, you can add this prop primary={false} on your <Router> component

  <Router primary={false}>
      <Search path="suche/:query" />
      <Blogs path="blogs" />
  </Router>

I had to use a combination of things to make it work. The scroll to top component will still work with you don't have primary={false}, but it causes jank from where it focuses the route and then window.scrollTo fires.

      <Router primary={false}>
        <ScrollToTop path="/">
          <Home path="/" />
          <Contact path="contact-us" />
          <ThankYou path="thank-you" />
          <WhoWeAre path="who-we-are" />
        </ScrollToTop>
      </Router>

This is based off of React Router's scroll restoration guide https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/docs/guides/scroll-restoration.md

// ScrollToTop.js
import React from 'react'

export const ScrollToTop = ({ children, location }) => {
  React.useEffect(() => window.scrollTo(0, 0), [location.pathname])
  return children
}

I'm using @mwood23 's solution it still has a bit of a jarring effect where the content loads first and then you're pulled to the top of the page. Setting Primary to false didn't do anything for me unfortunately.

solved for me Turns out the primary={false} did work for me. I had to do it for a nested router object I was using as well.

Here's an approach using a custom hook that works great for me!

In a file like helpers.js:

import {useRef} from 'react';

export function useScrollToTop() {
  const ref = useRef();
  const {current} = ref;
  if (current) current.scrollIntoView({alignToTop: true});
  return ref;
}

In every component that should scroll to the top when it is rendered, import the useScrollToTop function and change the top-most element to look like this:

<div ref={useScrollToTop()}>

The code in my previous post has an issue where interacting with a component can cause it to scroll to the top when no navigation has taken place. Here is a version that doesn't have this issue.

import {useRef} from 'react';

let lastURL;

export function useScrollToTop() {
  const ref = useRef();
  const {current} = ref;
  if (current && document.URL !== lastURL) {
    current.scrollIntoView({alignToTop: true});
    lastURL = document.URL;
  }
  return ref;
}

Here's my 5 cents (as i didn't want to disable the focus capability, only the scrollTo attached to it):

call:

<OnRouteChange actions={ ["scrollToTop"] } />

From:

import React from "react";
import { Location } from "@reach/router";

class OnRouteChangeWorker extends React.Component {
  componentDidUpdate(prevProps) {
    if (this.props.location.pathname !== prevProps.location.pathname) {
      if (Array.isArray(this.props.actions) && this.props.actions.length > 0){
        this.props.actions.forEach(action => {
          if ( typeof this[action] === "function" ) {
            this[action]();
          } else {
            console.log("No function definition for: " + action);
          }
        });
      }
    }
  };
  scrollToTop() {
    window.scrollTo(0, 0);
  };
  render() {
    return null
  };
}

const __SECRET_DO_NOT_DELETE_OR_YOU_WILL_BE_FIRED_PLACE_AFTER_MAIN_ROUTER_FOR_EXTENDING_HTML_FOCUS_USED_IN_REACH_ROUTER = ( { actions } ) => (
  <Location>
    { ( { location } ) => <OnRouteChangeWorker location={ location } actions={ actions } /> }
  </Location>
);

export default __SECRET_DO_NOT_DELETE_OR_YOU_WILL_BE_FIRED_PLACE_AFTER_MAIN_ROUTER_FOR_EXTENDING_HTML_FOCUS_USED_IN_REACH_ROUTER;

There is still an issue, if you want different behaviour:

  • Pressing Back button must restore scroll state
  • Using navigate' or` must scroll the page to the beginning.

I was using @mwood23 solution and it works. Thank you so much!

For anyone interested, I improved the solution suggested by @mwood23 so it doesn't scroll to the top when navigating using the browser's back/forward.

Code (typescript):

function ScrollToTop(props: React.PropsWithChildren<RouteComponentProps>) {
  const navigate = useNavigate();
  const { href, state } = useLocation();

  const updateState = React.useCallback(() => {
    navigate(href, {
      state: { ...state, scrolled: true },
      replace: true,
    }).then(() => window?.scrollTo(0, 0));
  }, [href, state, navigate]);

  // Mark the page as scrolled on first mount
  React.useEffect(updateState, []);

  React.useEffect(() => {
    if (!(state as any)?.scrolled) {
      updateState();
    }
  }, [state, updateState]);

  return (<>{props.children}</>);
}

Essentially, it scrolls up on any new navigation and then sets a flag in the state of the history stack so it knows not to scroll up again the next time it gets to this stack position.

I hope someone finds this useful.

I tried out a couple variations on @tasn's solution, but it seems that it breaks nested redirects. For root-level router redirects, you can pull the <Redirect> components out to be siblings of the <ScrollToTop> component, and they'll work. But that doesn't help if you have nested subroute redirects.

I instead created a hook called useResetScroll, sort of a combination of @tasn's with @mvolkmann's approach. I call the hook in each of my page-rendering route components but not in the intermediate subrouter components or other special redirecting components. It is mildly annoying to call the hook in every one of those files, but one nice thing about this approach is that you don't have to include the logic for pages that you know will be too short to scroll.

I was still getting some jankiness. Specifically, it looked like the page was scrolling a fraction of a second after painting. Fortunately, useLayoutEffect seems to do the trick for me. Full snippet below. Many thanks to everyone that shared their solutions.

function useResetScroll() {
  const navigate = useNavigate()
  const { href, state } = useLocation()

  const updateState = React.useCallback(() => {
    navigate(href, {
      state: { ...state, scrolled: true },
      replace: true,
    }).then(() => window.scrollTo(0, 0))
  }, [href, state, navigate])

  React.useLayoutEffect(() => {
    if (!state?.scrolled) {
      updateState()
    }
  }, [state, updateState])
}
Was this page helpful?
0 / 5 - 0 ratings

Related issues

artoodeeto picture artoodeeto  路  4Comments

ricardobrandao picture ricardobrandao  路  5Comments

alexluong picture alexluong  路  3Comments

thupi picture thupi  路  4Comments

EJIqpEP picture EJIqpEP  路  4Comments