Table of contents
    blog cover

    How to restore scroll position in React Router 6

    Web Development
    Web Development
    When developing a Single Page Application (SPA) with React and React Router V6, I encountered a scroll restoration issue. When I go to the new page, the scroll position persists instead of being on the top

    Although React Router V6 offers a ScrollRestoration component, you can try it first!
    Because my application requirements are quite complicated so I decided to create a custom ScrollRestoration.

    If you use V4 or V5, you can check this package: react-scroll-restoration

    Problem

    React Router behavior
    The scroll position persists between pages when navigating using Link/navigate/browser back button... in the app.

    Desired behavior
    • When I go to the new page, the scroll position should be on the top
    • When users visit page A, scroll down, and click a link that takes them to page B. If they navigate back to page A using the browser's back button, the scroll position should be restored. If they navigate back to A using a link to A on page B, the scroll position should be at the top of the page.

    Solution

    To address this problem, we need a mechanism to store and restore the scroll position for each page in our application.

    Instead of listening to popstate event (more details), I use key in the location object provided by React Router to detect if the browser back button clicked:
    • When users click a link or we call navigate function, the key changes
    • When users click a browser back button, the key does not change

    React Router V6

    Custom ScrollRestoration

    // language: javascript
    import React, { useEffect, useState } from 'react'
    import { useBlocker, useLocation } from 'react-router-dom'
    
    type ScrollStates = {
      [key: string]: number
    }
    type PageKeys = {
      [key: string]: string
    }
    
    const ScrollRestoration = () => {
      const location = useLocation()
      const scrollableParent = document.querySelector('main')
      
      const [pageKeys, setPageKeys] = useState<PageKeys>({})
      const [scrollStates, setScrollStates] = useState<ScrollStates>({})
      
      useEffect(() => {
        if (!scrollableParent) return
        const pathName = location.pathname
        const clickedBackButton = pageKeys[pathName] === location.key
    
        if (clickedBackButton) {
          scrollableParent.scrollTo(0, scrollStates[pathName] || 0)
        } else {
          scrollableParent.scrollTo(0, 0)
        }
      
        const newPageKeys = { ...pageKeys, [pathName]: location.key }
        setPageKeys(newPageKeys)  
      }, [location])
      
      useBlocker(() => {
        if (scrollableParent) {
          const newScrollState = {
            ...scrollStates,
            [location.pathname]: scrollableParent.scrollTop
          }
          setScrollStates(newScrollState)
        }
        return false
      })
      
      return <></>
    }
    
    export default ScrollRestoration

    Code Explanation
    • In most cases, scrollableParent will be the window object. However, in my project, I specifically target the <main> tag for scrolling purposes.
    • pageKeys is used to store the key associated with each pathname, while scrollStates tracks the scroll position for each pathname. Because I add the ScrollRestoration component in the React Router layout, it doesn't remount during navigation. If you place elsewhere, where pageKeys and scrollStates might be lost during navigation, consider using global state or sessionStorage instead of React state.
    • clickedBackButton = pageKeys[pathName] === location.key determines whether the back button was clicked by comparing the current pathname's key with the location key (explained above)
    • useBlocker is used to store the scroll position of the page before navigation (more details). It always returns false so as not to block navigation.

    Happy coding!
    Created at 2024-03-21 19:31:45 +0700

    Related blogs