I'm trying to use scroll position for my animations in my web portfolio. Since this portfolio use nextJS I can't rely on the window object, plus I'm using navigation wide slider so I'm not actually scrolling in the window but in a layout component called Page.
import React, { useEffect } from 'react';
import './page.css';
const Page = ({ children }) => {
useEffect(() => {
const scrollX = document.getElementsByClassName('page')
const scrollElement = scrollX[0];
console.log(scrollX.length)
console.log(scrollX)
scrollElement.addEventListener("scroll", function () {
console.log(scrollX[0].scrollTop)
});
return () => {
scrollElement.removeEventListener("scroll", () => { console.log('listener removed') })
}
}, [])
return <div className="page">{children}</div>;
};
export default Page;
Here is a production build : https://next-portfolio-kwn0390ih.vercel.app/
At loading, there is only one Page component in DOM.
The behaviour is as follow :
first listener is added at first Page mount, when navigating, listener is also added along with a new Page component in DOM.
as long as you navigate between the two pages, no new listener/page is added
if navigating to a third page, listener is then removed when the old Page is dismounted and a new listener for the third page is added when third page is mounted (etc...)
Problem is : when you navigate from first to second, everything looks fine, but if you go back to the first page you'll notice the console is logging the scrollX value of the second listener instead of the first. Each time you go on the second page it seems to add another listener to the same scrollElement even though it's not the same Page component.
How can I do this ? I'm guessing the two component are trying to access the same scrollElement somewhat :/
Thanks for your time.
Cool site. We don't have complete info, but I suspect there's an issue with trying to use document.getElementsByClassName('page')[0]. When you go to page 2, the log for scrollX gives an HTMLCollection with 2 elements. So there's an issue with which one is being targeted. I would consider using a refs instead. Like this:
import React, { useEffect, useRef } from 'react';
import './page.css';
const Page = ({ children }) => {
const pageRef = useRef(null)
const scrollListener = () => {
console.log(pageRef.current.scrollTop)
}
useEffect(() => {
pageRef.addEventListener("scroll", scrollListener );
return () => {
pageRef.removeEventListener("scroll", scrollListener )
}
}, [])
return <div ref={pageRef}>{children}</div>;
};
export default Page;
This is a lot cleaner and I think will reduce confusion between components about what dom element is being referenced for each scroll listener. As far as the third page goes, your scrollX is still logging the same HTMLElement collection, with 2 elements. According to your pattern, there should be 3. (Though there should really only be 1!) So something is not rendering properly on page 3.
If we see more code, it might uncover the error as being something else. If refs dont solve it, can you post how Page is implemented in the larger scope of things?
also, remove "junior" from the "junior developer" title - you won't regret it
Related
Is there any way to archive a page transition that fits the following requirements:
I want to show a page transition when a page changes
The page transition should finish before the next page loads/shows
The page transition should run X seconds
The page transition is a component placed in _app.js
Currently, I do this in a gruesome way.
In NuxtJS, it is possible to archive it via the Javascript Hooks. https://nuxtjs.org/docs/2.x/components-glossary/pages-transition
Thank you for the help :)
You can hook into Next.js Router events, but you can not set how much time the transition should take.
E.g. if you want the transition to be 3 seconds:
if the Next.js transition takes 1 second, you can wait 2 seconds, but
if the Next.js transition takes 4 seconds, you can't do anything about it.
And you can not know for sure how long the Next.js transition will take.
custom "routing"
Anyway, if you rely on the Next.js transition never taking more time than your animation, you would have to "store" the old page view somehow, and show it instead of the new page as long as the animation runs.
From Next.js perspective the new page should be shown. I think this would not work with Next.js routing, you would have to do the "routing" (display of content depending on the url) yourself, I guess. E.g. design your App to always have something like two pages at once, one is shown, one is hidden and might be loading. Then you can switch between these two pages whenever you want.
You might want to have a look at the experimental feature React "Suspense" (I don't know, I haven't used it).
wait before routing
If you want to wait a while before the transition starts, you might do something like this:
const startAnimation = () => {
return new Promise( ( resolve, reject ) => {
console.log('...animate...');
setTimeout( resolve, 3000 ); // <-- replace this line with your animation, and call resolve() when finished
} );
};
const routerWrapper = {
push: ( url ) => {
startAnimation().then( () => {
console.log('Next.js routing starts...');
Router.push( url );
})
}
};
// ...
routerWrapper.push( newUrl ); // <-- this instead of Router.push( newUrl )
stop animation when routing completed
If you want to start an animation when Route.push() is called, and stop it when the Next.js transition is completed, you can use Next.js routerevents, e.g.:
export default function App( props: AppProps ) {
const { Component, pageProps } = props;
const router = useRouter()
useEffect(() => {
const routeChangeStart = (url, { shallow }) => {
console.log('start animation');
};
const routeChangeComplete = (url, { shallow }) => {
console.log('stop animation');
};
router.events.on('routeChangeStart', routeChangeStart);
router.events.on('routeChangeComplete', routeChangeComplete);
return () => {
router.events.off('routeChangeStart', routeChangeStart);
router.events.off('routeChangeComplete', routeChangeComplete);
}
}, [])
// --
return (
<Component { ...pageProps } />
);
}
I have not personally done this yet, but it looks like you can do something like this with Framer Motion.
Destructuring props from the router in your main App component
function App({ Component, pageProps, router }) {
Then you can use the AnimatePresence component from Framer Motion to animate the route load and exit. You said you were familiar with the library in the comments so I won't talk about that component but here is what you add to the motion.div within the AnimatePresence component
<motion.div key={router.route}>
Below I will link a good article on how to do it and a good repo to use as an example. I did not write either of these but they are quite useful and descriptive.
https://www.freecodecamp.org/news/how-to-add-interactive-animations-and-page-transitions-to-a-next-js-web-app-with-framer-motion/#step-3-adding-page-transitions-with-framer-motion-to-a-next-js-app
https://github.com/colbyfayock/my-rick-and-morty-wiki/commit/3c1455370f750ff86b75a3b3edc446ebe553bf5b
How are you.
This is scenario of this issue.
Let's say there are 2 screens to make it simple.
enter A screen. useEffect of A screen called.
navigate to B screen from A screen
navigate back to A screen from B.
at this time, useEffect is not called.
function CompanyComponent(props) {
const [roleID, setRoleID] = useState(props.user.SELECTED_ROLE.id)
useEffect(()=>{
// this called only once when A screen(this component) loaded,
// but when comeback to this screen, it doesn't called
setRoleID(props.user.SELECTED_ROLE.id)
}, [props.user])
}
So the updated state of Screen A remain same when comeback to A screen again (Not loading from props)
I am not changing props.user in screen B.
But I think const [roleID, setRoleID] = useState(props.user.SELECTED_ROLE.id) this line should be called at least.
I am using redux-persist. I think this is not a problem.
For navigation, I use this
// to go first screen A, screen B
function navigate(routeName, params) {
_navigator.dispatch(
NavigationActions.navigate({
routeName,
params,
})
);
}
// when come back to screen A from B
function goBack() {
_navigator.dispatch(
NavigationActions.back()
);
}
Is there any callback I can use when the screen appears?
What is wrong with my code?
Thanks
Below solution worked for me:
import React, { useEffect } from "react";
import { useIsFocused } from "#react-navigation/native";
const ExampleScreen = (props) => {
const isFocused = useIsFocused();
useEffect(() => {
console.log("called");
// Call only when screen open or when back on screen
if(isFocused){
getInitialData();
}
}, [props, isFocused]);
const getInitialData = async () => {}
return (
......
......
)
}
I've used react navigation 5+
#react-navigation/native": "5.6.1"
When you navigate from A to B, component A is not destroyed (it stays in the navigation stack). Therefore, when you navigate back the code does not run again.
Perhaps a better way to acheive what you want to to use the navigation lifecycle events (I am assuming you are using react-navigation) I.e. subscribe to the didFocus event and run whatever code you want whenever the component is focussed E.g
const unsubscribe = props.navigation.addListener('didFocus', () => {
console.log('focussed');
});
Don't forget to unsubscribe when appropriate e.g.
// sometime later perhaps when the component is unmounted call the function returned from addListener. In this case it was called unsubscribe
unsubscribe();
The current version of React Navigation provides the useFocusEffect hook. See here.
React Navigation 5 provide a useFocusEffect hook, is analogous to useEffect, the only difference is that it only runs if the screen is currently focused. Check the documentation https://reactnavigation.org/docs/use-focus-effect
useFocusEffect(
useCallback(() => {
const unsubscribe = setRoleID(props.user.SELECTED_ROLE.id)
return () => unsubscribe()
}, [props.user])
)
The above mentioned solution would work definitely but in any case you need to know why it happens here is the reason,
In react native all the screens are stacked meaning they follow the LAST-IN-FIRST-OUT order, so when you are on a SCREEN A and go.Back(), the component(Screen A) would get un-mounted because it was the last screen that was added in the stack but when we naviagte to SCREEN B, it won't get un-mounted and the next SCREEN B would be added in the stack.
So now, when you go.Back() to SCREEN A, the useEffect will not run because it never got un-mounted.
React-native keeps the navigation this way to make it look more responsive and real time.
if you want to un-mount the Screen everytime you navigate to some other screen you might want to try navigation.replace than navigation.navigate
Hope this helps you.
import { useIsFocused } from "#react-navigation/native";
const focus = useIsFocused(); // useIsFocused as shown
useEffect(() => { // whenever you are in the current screen, it will be true vice versa
if(focus == true){ // if condition required here because it will call the function even when you are not focused in the screen as well, because we passed it as a dependencies to useEffect hook
handleGetProfile();
}
}, [focus]);
more
Solution with useEffect
import { useNavigation } from '#react-navigation/native';
const Component = (props) => {
const navigation = useNavigation()
const isFocused = useMemo(() => navigation.isFocused(), [])
useEffect(() => {
if (isFocused) {
// place your logic
}
}, [isFocused])
}
So here's the situation - When a Link is clicked the nprogress bar will start and I want react-router to only replace the current component with the matched route once that's done loading asynchronously.. just like in instagram..
But I am only getting this -
Here's my HOC to load component asynchronously --
import React, { useEffect, useState } from "react";
import nprogress from "nprogress";
import "nprogress/nprogress.css";
export default importComponent => props => {
const [loadedComponent, setComponent] = useState(null);
// this works like componentwillMount
if (!nprogress.isStarted()) nprogress.start();
if (loadedComponent) nprogress.done();
useEffect(() => {
let mounted = true;
mounted &&
importComponent().then(
({ default: C }) => mounted && setComponent(<C {...props} />)
);
// componentUnMount
return () => (mounted = false);
}, []);
// return the loaded component
const Component = loadedComponent || <div style={{ flexGrow: 1 }}>..</div>;
return Component;
};
I didn't find a solution to this anywhere on the internet.. so I am asking this question here in stackoverflow. I am hoping someone here can solve this.
Edit: 15-Nov-2021
The old approach doesn't work with routes that uses route params or query such as useLocation, useRouteMatch, etc.
The one I am using now is to use React.lazy, React.Suspense and updating the fallback prop whenever a page is rendered. So when the next page is being loaded, the fallback will be shown which is basically the same component instance as current page. Moreover, with this new approach, you can render the next page whenever you like; after fetching data from backend, or after animation, etc.
Here is a demo and source code.
I am currently using react-navigation to do stack- and tab- navigation.
Is it possible to re-render a component every time the user navigates to specific screens? I want to make sure to rerun the componentDidMount() every time a specific screen is reached, so I get the latest data from the server by calling the appropriate action creator.
What strategies should I be looking at? I am pretty sure this is a common design pattern but I failed to see documented examples.
If you are using React Navigation 5.X, just do the following:
import { useIsFocused } from '#react-navigation/native'
export default function App(){
const isFocused = useIsFocused()
useEffect(() => {
if(isFocused){
//Update the state you want to be updated
}
}, [isFocused])
}
The useIsFocused hook checks whether a screen is currently focused or not. It returns a boolean value that is true when the screen is focused and false when it is not.
React Navigation lifecycle events quoted from react-navigation
React Navigation emits events to screen components that subscribe to them. There are four different events that you can subscribe to: willFocus, willBlur, didFocus and didBlur. Read more about them in the API reference.
Let's check this out,
With navigation listeners you can add an eventlistener to you page and call a function each time your page will be focused.
const didBlurSubscription = this.props.navigation.addListener(
'willBlur',
payload => {
console.debug('didBlur', payload);
}
);
// Remove the listener when you are done
didBlurSubscription.remove();
Replace the payload function and change it with your "refresh" function.
Hope this will help.
You can also use also useFocusEffect hook, then it will re render every time you navigate to the screen where you use that hook.
useFocusEffect(()=> {
your code
})
At the request of Dimitri in his comment, I will show you how you can force a re-rendering of the component, because the post leaves us with this ambiguity.
If you are looking for how to force a re-rendering on your component, just update some state (any of them), this will force a re-rendering on the component. I advise you to create a controller state, that is, when you want to force the rendering, just update that state with a random value different from the previous one.
Add a useEffect hook with the match params that you want to react to. Make sure to use the parameters that control your component so it rerenders. Example:
export default function Project(props) {
const [id, setId] = useState(props?.match?.params?.id);
const [project, setProject] = useState(props?.match?.params?.project);
useEffect(() => {
if (props.match) {
setId(props.match?.params.id);
setProject(props.match?.params.project);
}
}, [props.match?.params]);
......
To trigger a render when navigating to a screen.
import { useCallback } from "react";
import { useFocusEffect } from "#react-navigation/native";
// Quick little re-render hook
function useForceRender() {
const [value, setValue] = useState(0);
return [() => setValue(value + 1)];
}
export default function Screen3({ navigation }) {
const [forceRender] = useForceRender();
// Trigger re-render hook when screen is focused
// ref: https://reactnavigation.org/docs/use-focus-effect
useFocusEffect(useCallback(() => {
console.log("NAVIGATED TO SCREEN3")
forceRender();
}, []));
}
Note:
"#react-navigation/native": "6.0.13",
"#react-navigation/native-stack": "6.9.0",
I'm experiencing some problems with scroll positions on the back button (history popstate) when using react-router. React router v4 doesn't handle scroll management out of the box because browsers are implementing some automatic scroll behavior. This is great except when the height of the browser window changes too dramatically from one view to another. I have implemented the ScrollToTop component as described here: https://reacttraining.com/react-router/web/guides/scroll-restoration
This works great. When you click a link and go to a different component, the browser scrolls to the top (like a normal server-rendered website would). The issue only happens when you go back (via the browser back button) to a view with a much taller window height. It seems that (chrome) tries to go to the scroll position of the previous page before react has rendered the content (and browser height). This results in the scroll only going as far down as it can based on the height of the view it's coming from. Picture this scenario:
View1: Long list of movies (window height 3500px).
(movie is clicked)
View2: Details view of the selected movie (window height: 1000px).
(Browser back button is clicked)
Back to view 1, but scroll position can't go further than 1000px, because chrome is trying to set the position before react renders the long movie list.
For some reason this is only a problem in Chrome. Firefox and Safari seem to handle it fine. I wonder if anyone else have had this problem, and how you guys generally handle scroll restoration in React.
Note: all the movies are imported from a sampleMovies.js — so I'm not waiting for an API response in my example.
Note that history.scrollRestoration is just a way of disabling the browser's automatic attempts at scroll restoration, which mostly don't work for single-page apps, so that they don't interfere with whatever the app wants to do. In addition to switching to manual scroll restoration, you need some sort of library that provides integration between the browser's history API, React's rendering, and the scroll position of the window and any scrollable block elements.
After not being able to find such a scroll restoration library for React Router 4, I created one called react-scroll-manager. It supports scrolling to top on navigation to a new location (aka history push) and scroll restoration on back/forward (aka history pop). In addition to scrolling the window, it can scroll any nested element that you wrap in an ElementScroller component. It also supports delayed/asynchronous rendering by using a MutationObserver to watch the window/element content up to a user-specified time limit. This delayed rendering support applies to scroll restoration as well as scrolling to a specific element using a hash link.
npm install react-scroll-manager
import React from 'react';
import { Router } from 'react-router-dom';
import { ScrollManager, WindowScroller, ElementScroller } from 'react-scroll-manager';
import { createBrowserHistory as createHistory } from 'history';
class App extends React.Component {
constructor() {
super();
this.history = createHistory();
}
render() {
return (
<ScrollManager history={this.history}>
<Router history={this.history}>
<WindowScroller>
<ElementScroller scrollKey="nav">
<div className="nav">
...
</div>
</ElementScroller>
<div className="content">
...
</div>
</WindowScroller>
</Router>
</ScrollManager>
);
}
}
Note that an HTML5 browser (10+ for IE) and React 16 are required. HTML5 provides the history API, and the library uses the modern Context and Ref APIs from React 16.
How do you handle your scroll restoration?
Turns out browsers have implementations of the history.scrollRestoration.
Maybe you can use that? Check these links out.
https://developer.mozilla.org/en/docs/Web/API/History#Specifications
https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
In addition, I found an npm module that might be able to handle scroll restoration in react with ease, but this library only works with react router v3 and below
https://www.npmjs.com/package/react-router-restore-scroll
https://github.com/ryanflorence/react-router-restore-scroll
I hope this can help.
my solution:save window.scrollY for every pathname with a Map (ES6)
scroll-manager.tsx
import { FC, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function debounce(fn: (...params: any) => void, wait: number): (...params: any) => void {
let timer: any = null;
return function(...params: any){
clearTimeout(timer);
timer = setTimeout(()=>{
fn(...params)
}, wait);
}
}
export const pathMap = new Map<string, number>();
const Index: FC = () => {
const { pathname } = useLocation();
useEffect(() => {
if (pathMap.has(pathname)) {
window.scrollTo(0, pathMap.get(pathname)!)
} else {
pathMap.set(pathname, 0);
window.scrollTo(0, 0);
}
}, [pathname]);
useEffect(() => {
const fn = debounce(() => {
pathMap.set(pathname, window.scrollY);
}, 200);
window.addEventListener('scroll', fn);
return () => window.removeEventListener('scroll', fn);
}, [pathname]);
return null;
};
export default Index;
App.tsx
function App() {
return (
<Router>
<ScrollManager/>
<Switch>...</Switch>
</Router>
);
}
You can also use pathMap.size === 1 to determine if the user entered the app for the first time
I wound up using localStorage to track the scroll position - not sure this would handle all situations.
In this example, there's a Company page with a set of Stores, and each Store has a set of Display cases. I needed to track the scroll position of the display cases, so saved that to a 'storeScrollTop' key. There were 6 lines of code to add.
company.jsx:
// click on a store link
const handleClickStore = (evt) => {
window.localStorage.removeItem('storeScrollTop') // <-- reset scroll value
const storeId = evt.currentTarget.id
history.push(`/store/${storeId}`)
}
store.jsx:
// initialize store page
React.useEffect(() => {
// fetch displays
getStoreDisplays(storeId).then(objs => setObjs(objs)).then(() => {
// get the 'store' localstorage scrollvalue and scroll to it
const scrollTop = Number(window.localStorage.getItem('storeScrollTop') || '0')
const el = document.getElementsByClassName('contents')[0]
el.scrollTop = scrollTop
})
}, [storeId])
// click on a display link
const handleClickDisplay = (evt) => {
// save the scroll pos for return visit
const el = document.getElementsByClassName('contents')[0]
window.localStorage.setItem('storeScrollTop', String(el.scrollTop))
// goto the display
const displayId = evt.currentTarget.id
history.push(`/display/${displayId}`)
}
The trickiest part was figuring out which element had the correct scrollTop value - I had to inspect things in the console until I found it.
This component scroll to up if page is new but if page seen before restore the scroll.
scroll-to-top.tsx file:
import { useEffect, FC } from 'react';
import { useLocation } from 'react-router-dom';
const pathNameHistory = new Set<string>();
const Index: FC = (): null => {
const { pathname } = useLocation();
useEffect((): void => {
if (!pathNameHistory.has(pathname)) {
window.scrollTo(0, 0);
pathNameHistory.add(pathname);
}
}, [pathname]);
return null;
};
export default Index;
app.tsx file:
<BrowserRouter>
<ScrollToTop />
<App />
</BrowserRouter>
Use this library
https://www.npmjs.com/package/react-scroll-restoration
React Router does not provide out of the box support for scroll restoration and as it currently stands they won't either, because browsers are implementing some automatic scroll behavior