Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active August 2, 2022 16:12
Show Gist options
  • Save nandorojo/45c00210c8c364017c3ab10a0e94698d to your computer and use it in GitHub Desktop.
Save nandorojo/45c00210c8c364017c3ab10a0e94698d to your computer and use it in GitHub Desktop.
React Native Web + Next.js Scroll Restoration

React Native/Expo Web + Next.js Scroll Restoration 🦉

I struggled with getting scroll restoration to work for a long time.

Imagine you're on a search screen, scrolled far down. You click an item, which takes you to a new screen. Then you go back, but you're back at the top of the search screen. It's a jarring UX.

Once I finally landed on a solution for Next.js, it didn't work with React Native Web. Why? Because react-native-web uses a ScrollView component, rather than window scrolling. This means that the common window.scrollTo solutions out there won't work.

The purpose of this gist is to make a drop-in solution that restores your scroll position whenever you use a FlatList or ScrollView and your users go back (i.e. they pop).

The files are all included in this gist. There's really only one you need (react-native-next-scroll-restore.ts); the rest are illustrative to show you what an example would look like.

In my case, I have some screens that use window scrolling, others that don't. This gist adds support for both. Apparently Next.js 10.x+ is going to have scroll restoration for window scrolling, so maybe one of them won't be needed. We shall see.

Step 0

Create the file that exports ReactNativeNextJsScrollRestore.

Step 1

Call ReactNativeNextJsScrollRestore.initialize() in your pages/_app.tsx file. This sets up a next/router tracker that checks if your user has gone "back". If they visited a page but did not go back, it deletes previous scroll history for that page, so that it doesn't restore scroll.

// pages/_app.js

import ReactNativeNextJsScrollRestore from '../react-native-next-scroll-restore'
import { useEffect } from 'react'

function MyApp({ Component, pageProps }) {
  useEffect(() => {
    const unsubscribe = ReactNativeNextJsScrollRestore.initialize()

    return () => {
      unsubscribe()
    }
  }, [])
  
  return <Component {...pageProps} />
}

Step 2

Now you need to hook up your React Native Scrollable.

// components/search-list

import React from 'react'
import ReactNativeNextJsScrollRestore from '../react-native-next-scroll-restore'
import { ScrollView, View } from 'react-native'

const { useScroller } = ReactNativeNextJsScrollRestore

export default function SearchList() {
  const scrollRef = React.useRef<ScrollView>(null)

  const { onScroll } = useScroller(scrollRef, {
    // optional, in ms
    scrollDelay: 500
  })

  // IMPORTANT: ScrollView parent must have a fixed height or flex: 1
  // See: https://github.com/necolas/react-native-web/issues/956
  return (
    <View style={{ flex: 1 }}>
      <ScrollView ref={scrollRef} onScroll={onScroll}>
        <YourScrollableContentHere />
      </ScrollView>
    </View>
  )
}

useScroller does two things here:

  1. It takes your scrollables ref as its first argument. This is used to scrollTo when the page mounts (if the user just went back, meaning there is potentially a scroll position to restore.)
  • You can also pass a scrollDelay option in the second argument. This lets you specify how long the function should sleep before it triggers your scroll. My default is 350. If your page takes a longggg time to render, maybe go longer. If you make this number too low, it might try to restore your scroll before your content has even rendered, which would render it useless.
  • If you want to add animated: boolean as an option here, you could do that too, but you'll have to edit react-native-next-scroll-restore.ts to pass the prop down. If you know you're scrolling for more than like 2000px, I might recommend disabling animation, since it can be kind of choppy for such a long distance.
  1. Create a simple onScroll function that you pass to your ScrollView. This updates the scrollViewMemories variable with the latest scroll position. That way, if we visit this page again in the future, we know where the user left off.

That's it!

If you've made it to the end of this random GitHub Gist's readme, congrats. You've run into quite the niche problem.

It took me a long time to get to a solution for this. Hopefully, it only takes you a few minutes.

If this helped you out, let me know on Twitter (fernandotherojo) so it will feel worth it.

Relevant issues

// pages/_app.js
import ReactNativeNextJsScrollRestore from '../react-native-next-scroll-restore'
import { useEffect } from 'react'
function MyApp({ Component, pageProps }) {
useEffect(() => {
const unsubscribe = ReactNativeNextJsScrollRestore.initialize()
return () => {
unsubscribe()
}
}, [])
return <Component {...pageProps} />
}
import type { ScrollView, FlatList } from 'react-native'
import { Platform } from 'react-native'
import {
useEffect,
useCallback,
ComponentProps,
RefObject,
useRef,
} from 'react'
import Router from 'next/router'
const windowScrollMemories: { [asPath: string]: number } = {}
const scrollViewMemories: {
[asPath: string]: {
scrollY: number
isPop: boolean
}
} = {}
/**
* The following is taken/edited from `useScrollToTop` from `@react-navigation/native`
*/
type ScrollOptions = { y?: number; animated?: boolean }
type ScrollableView =
| { scrollToTop(): void }
| { scrollTo(options: ScrollOptions): void }
| { scrollToOffset(options: { offset?: number; animated?: boolean }): void }
| { scrollResponderScrollTo(options: ScrollOptions): void }
type ScrollableWrapper =
| { getScrollResponder(): React.ReactNode }
| { getNode(): ScrollableView }
| ScrollableView
function getScrollableNode(ref: React.RefObject<ScrollableWrapper>) {
if (ref.current == null) {
return null
}
if (
'scrollTo' in ref.current ||
'scrollToOffset' in ref.current ||
'scrollResponderScrollTo' in ref.current
) {
// This is already a scrollable node.
return ref.current
} else if ('getScrollResponder' in ref.current) {
// If the view is a wrapper like FlatList, SectionList etc.
// We need to use `getScrollResponder` to get access to the scroll responder
return ref.current.getScrollResponder()
} else if ('getNode' in ref.current) {
// When a `ScrollView` is wraped in `Animated.createAnimatedComponent`
// we need to use `getNode` to get the ref to the actual scrollview.
// Note that `getNode` is deprecated in newer versions of react-native
// this is why we check if we already have a scrollable node above.
return ref.current.getNode()
} else {
return ref.current
}
}
/**
* End of react-navigation code.
*/
type OnScroll = NonNullable<
ComponentProps<typeof ScrollView | typeof FlatList>['onScroll']
>
let isPop = false
/**
* @param scrollViewRef The `ref` passed to your `ScrollView`.
*/
function useScroller(
scrollViewRef: RefObject<ScrollableWrapper>,
{
scrollDelay: _scrollDelay = 350,
shouldAnimateScroll: _shouldAnimateScroll = (scrollY) => scrollY < 3000,
}: {
/**
* Number of milliseconds the page should wait before triggering `scrollTo`.
*
* This allows content to render before it requires a scroll
*
* Default: `350`. Set it to a larger number if you expect a long
*
* It also accepts a function that returns a number. The function receives `scrollY`.
*
* For example:
*
* `scrollDelay = (scrollY) => 350 + scrollY / 10`
*/
scrollDelay?: number | ((scrollY: number) => number)
/**
* Determine whether or not the scroll should animate.
*
* You can either pass a `boolean`, or a function which returns a boolean.
*
* If you pass a function, it will receive the `scrollY` as its only argument. This lets you determine if it should animate based on how far it's scrolling.
*
* By default, this value is `true` as long as the scroll is under 3000px, but you might want to do something like this:
*
* `(scrollToY) => scrollToY < 2000`
*
* This means your scroll will be animated, as long as the `scrollToY` value is under 2000. If it's too long, it can get a bit choppy with the animation.
*/
shouldAnimateScroll?: boolean | ((scrollY: number) => boolean)
} = {}
) {
const hasScrolled = useRef(false)
const scrollDelay = useRef(_scrollDelay)
const shouldAnimateScroll = useRef(_shouldAnimateScroll)
useEffect(() => {
shouldAnimateScroll.current = _shouldAnimateScroll
scrollDelay.current = _scrollDelay
})
useEffect(
function maybeRestoreScroll() {
if (Platform.OS !== 'web' || typeof window === 'undefined') return
// de-dupe scrolling more than once on a given mount
if (hasScrolled.current) return
// this is mostly taken from react-navigation useScrollToTop
let timeout = 0
requestAnimationFrame(() => {
const path = Router.asPath
const memory = scrollViewMemories[path]
if (!memory) return
const { scrollY, isPop } = memory
if (!isPop || !scrollY) return
const animated =
typeof shouldAnimateScroll.current === 'function'
? shouldAnimateScroll.current(scrollY)
: shouldAnimateScroll.current
const delay =
typeof scrollDelay.current === 'function'
? scrollDelay.current(scrollY)
: scrollDelay.current
timeout = setTimeout(() => {
const scrollable = getScrollableNode(
scrollViewRef
) as ScrollableWrapper
if ('scrollTo' in scrollable) {
scrollable.scrollTo({ y: scrollY, animated })
hasScrolled.current = true
} else if ('scrollToOffset' in scrollable) {
scrollable.scrollToOffset({ offset: scrollY, animated })
hasScrolled.current = true
} else if ('scrollResponderScrollTo' in scrollable) {
scrollable.scrollResponderScrollTo({ y: scrollY, animated })
hasScrolled.current = true
}
if (hasScrolled.current) {
scrollViewMemories[path].isPop = false
}
}, delay)
})
return () => {
clearTimeout(timeout)
}
},
[scrollViewRef]
)
/**
* Update the scroll position whenever we scroll. This must be passed to your `ScrollView`.
*/
const onScroll = useCallback<OnScroll>(({ nativeEvent }) => {
if (Platform.OS === 'web') {
scrollViewMemories[Router.asPath] = {
...scrollViewMemories[Router.asPath],
scrollY: nativeEvent.contentOffset.y,
}
}
}, [])
return {
onScroll,
}
}
/**
* This function should be called in `pages/_app.tsx`, outside of render code.
*
* https://github.com/vercel/next.js/issues/1309
* https://github.com/vercel/next.js/issues/1309#issuecomment-690957041
*/
function nextjsScrollPositionRestorer() {
if (process.browser) {
window.history.scrollRestoration = 'manual'
window.onpopstate = () => {
isPop = true
}
}
Router.events.on('routeChangeStart', () => {
saveScroll()
})
Router.events.on('routeChangeComplete', () => {
const path = Router.asPath
if (isPop) {
restoreWindowScroll()
setScrollViewPositionPopStatus(path, true)
isPop = false
} else {
setScrollViewPositionPopStatus(path, false)
// ok, this is a point of contention for me.
// we're only going to restore the scroll if it's a pop...
// which is right.
// but imagine this scenario:
/**
* Search screen (scroll down) -> artist profile -> click search screen again.
*
* Ok, so in this case, we should *not* restore scroll.
*
* However, what if we now do this?
*
* go back => profile screen. go back again => search screen.
*
* Now, we've popped back, and it SHOULD go back to the search screen.
*
* Thus, rather than deleting the scroll value, I think we should do it like this:
*
* scrollViewMemories[Router.asPath] = { scrollY, isPop }
*
* And if `isPop` is false, then in `useScroller`, we ignore it.
*/
windowScrollToTop()
}
})
function saveScroll() {
windowScrollMemories[Router.asPath] = window.scrollY
}
function setScrollViewPositionPopStatus(path: string, isPop: boolean) {
scrollViewMemories[path] = {
...scrollViewMemories[path],
isPop,
}
}
function restoreWindowScroll() {
const prevWindowScrollY = windowScrollMemories[Router.asPath]
if (prevWindowScrollY !== undefined) {
window.requestAnimationFrame(() =>
setTimeout(window.scrollTo(0, prevWindowScrollY), 150)
)
}
}
function windowScrollToTop() {
window.requestAnimationFrame(() => window.scrollTo(0, 0))
}
}
const ReactNativeNextJsScrollRestore = {
initialize: nextjsScrollPositionRestorer,
useScroller,
}
export default ReactNativeNextJsScrollRestore
// components/search-list
import React from 'react'
import ReactNativeNextJsScrollRestore from '../react-native-next-scroll-restore'
import { ScrollView, View } from 'react-native'
const { useScroller } = ReactNativeNextJsScrollRestore
export default function SearchList() {
const scrollRef = React.useRef<ScrollView>(null)
const { onScroll } = useScroller(scrollRef, {
// optional, in ms
scrollDelay: 500
})
// IMPORTANT: ScrollView parent must have a fixed height or flex: 1
// See: https://github.com/necolas/react-native-web/issues/956
return (
<View style={{ flex: 1 }}>
<ScrollView ref={scrollRef} onScroll={onScroll}>
<YourScrollableContentHere />
</ScrollView>
</View>
)
}
// pages/search.js
import React from 'react'
import SearchList from '../components/search-list'
export default function SearchPage() {
// scrollable content can be in the root page file, or in nested files 😎
return <SearchList />
}
@nandorojo
Copy link
Author

scroll restoration doesn’t exist on native, because the screen never unmounts and thus retains its scroll position

@nandorojo
Copy link
Author

FWIW, you can also opt out of using a scroll view on Web altogether, and then Next.js will restore scroll for you. This is outlined in the solito docs: https://solito.dev/recipes/scroll-view

@nandorojo
Copy link
Author

FWIW, you can also opt out of using a scroll view on Web altogether, and then Next.js will restore scroll for you. This is outlined in the solito docs: https://solito.dev/recipes/scroll-view

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment