Sancho
Sancho

Reputation: 1390

React Router v4 - Keep scrolling position when switching components

I have two <Route>s created with react-router.

When the user clicks on the "Return to list", I want to scroll the user where he was on the list.

How can I do this?

Upvotes: 30

Views: 26565

Answers (7)

Unmitigated
Unmitigated

Reputation: 89442

Since React Router 6.4, the ScrollRestoration component can be used for this. It requires using a data router, such as one created by calling createBrowserRouter (which is recommended for all React Router web projects).

This component will emulate the browser's scroll restoration on location changes after loaders have completed to ensure the scroll position is restored to the right spot, even across domains.

To save the scroll position for each different path, pass a getKey function to ScrollRestoration that returns location.pathname as the key used to differentiate the scroll position of routes.

Render the ScrollRestoration once in the root component to apply it to all paths:

import { ScrollRestoration } from 'react-router-dom';
function App() {
    return <>
        <div>Content</div>
        <ScrollRestoration getKey={(location, matches) => location.pathname}/>
    </>;
}

If getKey is not set, the default behavior is to reset the scroll position to the top when navigating to a new page (e.g. by clicking a link) and maintain the original scroll position when returning to a previous page (e.g. by clicking the browser's back button).

Upvotes: 0

Larprad
Larprad

Reputation: 11

I just ran into this issue. I found this solution that seems to do the job fairly well:

export const useKeepScrollPositionOnRouteChange = () => {
    const route = useLocation();

    useLayoutEffect(() => {
        const { scrollY, requestAnimationFrame, scrollTo } = window;
        requestAnimationFrame(() => {
            scrollTo(0, scrollY);
        });
    }, [route]);
};

First post on stackoverflow!

Upvotes: 1

PR7
PR7

Reputation: 1914

I faced a similar issue in one of my React projects in which we were using functional components. I created a solution by using the answers provided by @Shortchange and @Agus Syahputra as the exact solutions provided were not working in my case for some reason.

I created a useScrollMemory custom hook as per @Shortchange's answer but with some minor changes. The useScrollMemory function here takes scrollData object as parameter which is a global object that stores the scroll position for the visited pathnames as per @Agus Syahputra's answer. The scrollData object is initialised in the App component.

useScrollMemory.js

import { useEffect } from 'react';
import { useHistory } from 'react-router-dom';

/**
 * @description Overrides the PUSH and REPLACE methods in history to
 * save the window scroll position for the route.
 *
 * @param { Object } scrollData - Contains pathname and its scroll position.
 */
const useScrollMemory = (scrollData) => {
  const history = useHistory();

  useEffect(() => {
    const { push, replace } = history;

    // Override the history PUSH method to automatically set scroll state.
    history.push = (path, state = {}) => {
      scrollData[history.location.pathname] = window.scrollY;
      push(path, state);
    };

    // Override the history REPLACE method to automatically set scroll state.
    history.replace = (path, state = {}) => {
      scrollData[history.location.pathname] = window.scrollY;
      replace(path, state);
    };

    // Listen for location changes and set the scroll position accordingly.
    const unregister = history.listen((location, action) => {
      window.scrollTo(
        0,
        action !== 'POP' ? 0 : scrollData[location.pathname] ?? 0,
      );
    });

    // Unregister listener when component unmounts.
    return () => {
      unregister();
    };
  }, [history]);
};

export default useScrollMemory;

And in the App component call the useScrollMemory in the beginning:

App.js

import useScrollMemory from '../../hooks/useScrollMemory';

const scrollData = {};

const App = () => {
  useScrollMemory(scrollData);

  return <div>My app</div>;
}

export default App;

Upvotes: 1

Shortchange
Shortchange

Reputation: 446

Since there is no answer for how to do this in a functional component, here's a hook solution I implemented for a project:

import React from 'react';
import { useHistory } from 'react-router-dom';

function useScrollMemory(): void {
  const history = useHistory<{ scroll: number } | undefined>();

  React.useEffect(() => {
    const { push, replace } = history;

    // Override the history PUSH method to automatically set scroll state.
    history.push = (path: string) => {
      push(path, { scroll: window.scrollY });
    };
    // Override the history REPLACE method to automatically set scroll state.
    history.replace = (path: string) => {
      replace(path, { scroll: window.scrollY });
    };

    // Listen for location changes and set the scroll position accordingly.
    const unregister = history.listen((location, action) => {
      window.scrollTo(0, action !== 'POP' ? 0 : location.state?.scroll ?? 0);
    });

    // Unregister listener when component unmounts.
    return () => {
      unregister();
    };
  }, [history]);
}

function App(): JSX.Element {
  useScrollMemory();

  return <div>My app</div>;
}

With this override solution, you need not to worry about passing the state in all of your Link elements. An improvement would be making it generic so it's backwards compatible with the push and replace methods of history but it wasn't a requirement in my particular case so I omitted it.

I'm using react-router-dom but you could just as easily override the methods of the vanilla historyAPI.

Upvotes: 8

Agus Syahputra
Agus Syahputra

Reputation: 446

For full implementation using Redux you can see this on CodeSandbox.

I did this by utilizing the history API.

  1. Save scroll position after a route change.

  2. Restore the scroll position when the user clicks the back button.

Save the scroll position in getSnapshotBeforeUpdate and restore it in componentDidUpdate.

  // Saving scroll position.
  getSnapshotBeforeUpdate(prevProps) {
    const {
      history: { action },
      location: { pathname }
    } = prevProps;

    if (action !== "POP") {
      scrollData = { ...scrollData, [pathname]: window.pageYOffset };
    }

    return null;
  }

  // Restore scroll position.
  componentDidUpdate() {
    const {
      history: { action },
      location: { pathname }
    } = this.props;

    if (action === "POP") {
      if (scrollData[pathname]) {
        setTimeout(() =>
          window.scrollTo({
            left: 0,
            top: scrollData[pathname],
            behavior: "smooth"
          })
        );
      } else {
        setTimeout(window.scrollTo({ left: 0, top: 0 }));
      }
    } else {
      setTimeout(window.scrollTo({ left: 0, top: 0 }));
    }
  }

Upvotes: 2

Roy Scheffers
Roy Scheffers

Reputation: 3908

Working example at codesandbox

React Router v4 does not provide out of the box support for scroll restoration and as it currently stands they won't either. In section React Router V4 - Scroll Restoration of their docs you can read more about it.

So, it's up to every developer to write logic to support this although, we do have some tools to make this work.

element.scrollIntoView()

.scrollIntoView() can be called on an element and as you can guess, it scrolls it into view. Support is quite good, currently, 97% of browsers support it. Source: icanuse

The <Link /> component can pass on state

React Router's Link component has a to prop which you can provide an object instead of a string. Here's who this looks.

<Link to={{ pathname: '/card', state: 9 }}>Card nine</Link>

We can use state to pass on information to the component that will be rendered. In this example, state is assigned a number, which will suffice in answering your question, you'll see later, but it can be anything. The route /card rendering <Card /> will have access to the variable state now at props.location.state and we can use it as we wish.

Labeling each list item

When rendering the various cards, we add a unique class to each one. This way we have an identifier that we can pass on and know that this item needs to be scrolled into view when we navigate back to the card list overview.

Solution

  1. <Cards /> renders a list, each item with a unique class;
  2. When an item is clicked, Link /> passes the unique identifier to <Card />;
  3. <Card /> renders card details and a back button with the unique identifier;
  4. When the button is clicked, and <Cards /> is mounted, .scrollIntoView() scrolls to the item that was previously clicked using the data from props.location.state.

Below are some code snippets of various parts.

// Cards component displaying the list of available cards.
// Link's to prop is passed an object where state is set to the unique id.
class Cards extends React.Component {
  componentDidMount() {
    const item = document.querySelector(
      ".restore-" + this.props.location.state
    );
    if (item) {
      item.scrollIntoView();
    }
  }

  render() {
    const cardKeys = Object.keys(cardData);
    return (
      <ul className="scroll-list">
        {cardKeys.map(id => {
          return (
            <Link
              to={{ pathname: `/cards/${id}`, state: id }}
              className={`card-wrapper restore-${id}`}
            >
              {cardData[id].name}
            </Link>
          );
        })}
      </ul>
    );
  }
}

// Card compoment. Link compoment passes state back to cards compoment
const Card = props => {
  const { id } = props.match.params;
  return (
    <div className="card-details">
      <h2>{cardData[id].name}</h2>
      <img alt={cardData[id].name} src={cardData[id].image} />
      <p>
        {cardData[id].description}&nbsp;<a href={cardData[id].url}>More...</a>
      </p>
      <Link
        to={{
          pathname: "/cards",
          state: props.location.state
        }}
      >
        <button>Return to list</button>
      </Link>
    </div>
  );
};

// App router compoment.
function App() {
  return (
    <div className="App">
      <Router>
        <div>
          <Route exact path="/cards" component={Cards} />
          <Route path="/cards/:id" component={Card} />
        </div>
      </Router>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Upvotes: 21

Jon Wyatt
Jon Wyatt

Reputation: 1936

One other possible solution to this is to render your /cards/:id route as a full screen modal and keep the /cards route mounted behind it

Upvotes: 3

Related Questions