restoring scroll position in react project(hooks) - javascript

I have searched a few answers regarding my issue but found one relevant. Unfortunately, it is even on the class component, so I want to restore the scroll position after navigating back on the functional component. Here I will share the source code link on Stackblitz

There are few issues here.
First:
setPosts(res.data.slice(0, 20), handleScrollPosition());
You see, setter in useState does not have second argument as this.setState() has. But why, can you ask, your handleScrollPosition is called at all?(since you can see it's called in debugger). The reason is that you've written this as handleScrollPosition() so it's called immediately. To demonstrate my point, if you change it to correct "passing a callback" form:
setPosts(res.data.slice(0, 20), handleScrollPosition);
As you already have in class-based version, you will see it's never called. Similarly,
[].map(() => {}, 1,2,3,4, handleScrollPosition())
will also call handleScrollPosition() even though obviously [].map() does not process 6th argument.
So what you should do? First, let's move scroll restoring into separate useEffect:
useEffect(() => {
const scrollPosition = sessionStorage.getItem("scrollPosition");
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem("scrollPosition");
}
}, []);
obviously it did not work well, since it will be called immediately, before data is fetched. So let's add dependency on posts:
useEffect(() => {...
}, [posts]);
But it will not work correctly, still. The reason is it's triggered on initial render when posts is empty... so let's add check "only after posts are loaded":
useEffect(() => {
if (posts.length) {
const scrollPosition = sessionStorage.getItem("scrollPosition");
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem("scrollPosition");
}
}
}, [posts]);
Now it works as expected and restores scroll position.
PS btw, I don't think that writting position to sessionStorage is the best approach. Working with multiple tabs will make a mess. If speaking without sample code, I'd see alternative by making separate route "also the list but with ID of element we should scroll to". And then link "back to the list" will target that route, with passing of entity ID you are currently viewing details for.

Related

Do I really need to always have exhaustive deps for useEffect and if so, what is wrong with my thinking here?

Consider the following example (wrote it from memory, so could have some issues):
export function App(props) {
const [howManyTimesUserHasDraggedMap, setHowManyTimesUserHasDraggedMap] = useState(0);
const [mapCenter, setMapCenter] = useState<LatLng>(new LatLng(0, 0));
const [shouldLog, setShouldLog] = useState(true);
const handleCenterChange = (newCenter: LatLng) : void => {
setMapCenter(newCenter);
}
useEffect(() =>
{
setHowManyTimesUserHasDraggedMap((prev) => prev + 1);
if(shouldLog)
console.log('Updated map drag count');
} [mapCenter]);
return (
<MapComponent onCenterChange={handleCenterChange} />
);
}
The point here is that we have, for example, some component called Map which shows Google Maps in a modal and user can interact with the map by dragging and zooming it.
Lets say that I want to keep track of how many time the user has dragged the map, which is basically the same as changing the center of it. The Map component takes onCenterChange prop and I give it a function which updates the mapCenter state with the newCenter whenever the center changes.
Now this example is a bit dumb (I could do all this in the handleCenterChange, but that's beside the point), but for whatever reason I want to use useEffect here to increment the number of drags whenever the mapCenter state changes, so I put mapCenter to the dependency array because that seems like the logical thing to do. I do also have an if-statement there which checks if shouldLog is true and console.logs something irrelevant to the console if so.
Now the issue here is that the dependencies for that useEffect are not exhaustive since shouldLog is missing from the dependency array and I'm getting warnings left and right. I then go read some documentation and there are warnings and alerts everywhere to ALWAYS INCLUDE ALL THE DEPENDENCIES.
But that doesn't really make sense here now does it? Why should I trigger that useEffect if my shouldLog-state changes - that doesn't mean that the mapCenter has changed. shouldLog's value is only relevant if the center changes.
My current understanding is that I can use useEffect to basically subscribe into certain events, such as when the component mounts, when the component unmounts, when the component re-renders or when something in the dependency array changes. So in this case I am subscribing to an event that fires when mapCenter changes and that is when it should ever fire. When the event fires though, it just happens to check what it should do regarding to logging, but the change in logging isn't a reason to fire the event.
So is my understanding completely wrong here or what is going on?

Next.js behavior on back button pressed

I have a page I am trying to fix in order to keep scroll position when user presses back button (browser). Let's say I have a component called list, where I show the user some products. To see all the products the user can scroll down the list component. When the user clicks on some product, the application redirects the user to the detail component. Then when the user tries to go back to the list, hits the back button of the browser, the list component gets rendered and it seems like it scrolls to top automatically.
As far as I know, pressing the back button of the browser triggers a window.history.back() action, nothing else happens.
For a solution, I have implemented a variable in the context of my application that saves the scrollY value and then, in the componentWillMount (or useEffect) of the component I am trying to render (list component), I set the scroll position to the value set in the context.
Details of my solution are here, as I have based my entire code in this stack overflow's post:
How to change scroll behavior while going back in next js?
I have checked the value using some logs and the scroll position is saved correctly in the context, however, as I am using a window event listener, it sets the value to zero just after the list component is rendered.
In my code I am not using any kind of scroll configuration, so I was wondering if that behavior is some sort of default for either Next.js or react. It happens when the user hits the back button of the browser, but I am a newbie to next and I don't know if I am missing something or what, I don't even know if this issue has something to do with React or Next.js itself.
This gist may be of assistance as it includes a custom hook to manage scroll position: https://gist.github.com/claus/992a5596d6532ac91b24abe24e10ae81
import { useEffect } from 'react';
import Router from 'next/router';
function saveScrollPos(url) {
const scrollPos = { x: window.scrollX, y: window.scrollY };
sessionStorage.setItem(url, JSON.stringify(scrollPos));
}
function restoreScrollPos(url) {
const scrollPos = JSON.parse(sessionStorage.getItem(url));
if (scrollPos) {
window.scrollTo(scrollPos.x, scrollPos.y);
}
}
export default function useScrollRestoration(router) {
useEffect(() => {
if ('scrollRestoration' in window.history) {
let shouldScrollRestore = false;
window.history.scrollRestoration = 'manual';
restoreScrollPos(router.asPath);
const onBeforeUnload = event => {
saveScrollPos(router.asPath);
delete event['returnValue'];
};
const onRouteChangeStart = () => {
saveScrollPos(router.asPath);
};
const onRouteChangeComplete = url => {
if (shouldScrollRestore) {
shouldScrollRestore = false;
restoreScrollPos(url);
}
};
window.addEventListener('beforeunload', onBeforeUnload);
Router.events.on('routeChangeStart', onRouteChangeStart);
Router.events.on('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => {
shouldScrollRestore = true;
return true;
});
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
Router.events.off('routeChangeStart', onRouteChangeStart);
Router.events.off('routeChangeComplete', onRouteChangeComplete);
Router.beforePopState(() => true);
};
}
}, [router]);
}
Looking at your url, using shallow routing could solve the problem. Where the URL will get updated. And the page won't get replaced, only the state of the route is changed. So you can change your logic according to that.
A good example is in the official documentation:
https://nextjs.org/docs/routing/shallow-routing
And you might use display: 'hidden' to hide and show your components conditionally according to your state!
It's a way around but it could be even more useful depending on your exact situation !
After looking for another solution that does not use the window.scroll and similar methods, I have found a solution.
1st solution (worked, but for me that I have an infinite list that is loaded via API call, sometimes the window.scroll method wasn't accurate): I take the window.scrollY value and set it in the session storage, I did this before leaving the list page, so in the details page, if user hits the back button, at the moment the page is loading, I get the Y value from session storage and use the window.scroll method to force the page to scroll to the previously configured value.
As I mentioned earlier, this worked, but in my case, I have a list that is populated from an async API call, so sometimes the page loaded without all the images and the scroll was already configured, then the images and data were loaded and the user ended up seeing some other place in the page rather than the desire position.
2nd solution: In my case we are talking about a e commerce app, so I found this solution useful as it focuses in a particular item with its corresponding ID instead of the Y coord of the window. Scroll Restoration in e commerce app

Concurrent-mode and sync flushing of setState in click handlers

Does the upcoming concurrent-mode break the old guarantee, that setState updates within a click handler are flushed synchronously at the event boundary?
If i have e.g. a button, that should only ever be pressed once, a supposedly working pattern was to "just set the state to disabled in the click handler":
let counter = 0;
const C = () => {
const [disabled, setDisabled] = React.useState(false);
const handler = React.useCallback(
() => { setDisabled(true); counter++; },
[], // setDisabled is guaranteed to never change
);
return (<button onClick={handler} disabled={disabled}>click me</button>);
};
// Assert: `counter` can never be made >1 by clicking the button with one C
This pattern used to be guaranteed to work (at least given that setting the disabled-attribute prevents any further click events, which seems to be the case). The biggest related question i could find discusses this, and also shows a more or less obvious alternative (and easier to prove it works), of using a ref (unlike the answer in the linked question, maybe rather a boolean ref, but same idea, it's always sync).
Side questions: Is this information up-to-date, or did something change? It's more than three years old after all. It mentions "interactive events (such as clicks)", what are the others?
However, in concurrent-mode, rendering can be paused, which i interpret as "the js thread will be released", to allow potential key presses or whatever events to trickle in, and in that phase, additional click events could also happen, before the next render disables the button. Is therefore the way to go to use some kind of ref, or maybe explicitly adding ReactDOM.flushSync?
My current understanding of how concurrent mode works is this:
1 - a re-render starts
2 - hooks are called, they change internal state
3a - re-render is suspended
4a - internal state changes are rolled back
OR
3b - re-render is not suspended
4b - internal state changes are commited
useCallback is a thin wrapper over useMemo and uses "internal state" to save the cached value. (4a) is the key here, and from what I understand your solution is not guaranteed to work anymore.
The useRef (with a boolean flag value) solution has the same issue too because you're not guaranteed that the new value of the ref is actually going to be "commited" when re-rendering is suspended.
The useRef solution where you keep a ref to the DOM button element and directly manipulate the disabled attribute will still work even in concurrent mode. React has no way of blocking you from directly manipulating DOM.
"suspending" means reverting "internal state" + not applying the generated DOM manipulations, does not mean any side effects (like manipulating DOM directly) can be affected.
flushSync will not help either, it simply forces re-renders, does not guarantee that the current render won't be suspended.
As far as I know the setState call was always async, and you never had a way to warranty that the button will be disabled right after the click. Also there is no such thing as concurrency in JS, it has single thread, the problem is that the render can happen latter than you expect so you can receive another click until React made re-render for you.
If you need to fire the logic only once I would advice to use useRef hook and when you need to make sure that we have not clicked the button just check the value.
const isDisabled = useRef(false);
const onClick = () => {
if (!isDisabled.current) {
isDisabled.current = true;
}
}

how to emulate messages/events with react useState and useContext?

I'm creating a react app with useState and useContext for state management. So far this worked like a charm, but now I've come across a feature that needs something like an event:
Let's say there is a ContentPage which renders a lot of content pieces. The user can scroll through this and read the content.
And there's also a BookmarkPage. Clicking on a bookmark opens the ContentPage and scrolls to the corresponding piece of content.
This scrolling to content is a one-time action. Ideally, I would like to have an event listener in my ContentPage that consumes ScrollTo(item) events. But react pretty much prevents all use of events. DOM events can't be caught in the virtual dom and it's not possible to create custom synthetic events.
Also, the command "open up content piece XYZ" can come from many parts in the component tree (the example doesn't completely fit what I'm trying to implement). An event that just bubbles up the tree wouldn't solve the problem.
So I guess the react way is to somehow represent this event with the app state?
I have a workaround solution but it's hacky and has a problem (which is why I'm posting this question):
export interface MessageQueue{
messages: number[],
push:(num: number)=>void,
pop:()=>number
}
const defaultMessageQueue{
messages:[],
push: (num:number) => {throw new Error("don't use default");},
pop: () => {throw new Error("don't use default");}
}
export const MessageQueueContext = React.createContext<MessageQueue>(defaultMessageQueue);
In the component I'm providing this with:
const [messages, setmessages] = useState<number[]>([]);
//...
<MessageQueueContext.Provider value={{
messages: messages,
push:(num:number)=>{
setmessages([...messages, num]);
},
pop:()=>{
if(messages.length==0)return;
const message = messages[-1];
setmessages([...messages.slice(0, -1)]);
return message;
}
}}>
Now any component that needs to send or receive messages can use the Context.
Pushing a message works as expected. The Context changes and all components that use it re-render.
But popping a message also changes the context and also causes a re-render. This second re-render is wasted since there is no reason to do it.
Is there a clean way to implement actions/messages/events in a codebase that does state management with useState and useContext?
Since you're using routing in Ionic's router (React-Router), and you navigate between two pages, you can use the URL to pass params to the page:
Define the route to have an optional path param. Something like content-page/:section?
In the ContentPage, get the param (section) using React Router's useParams. Create a useEffect with section as the only changing dependency only. On first render (or if section changes) the scroll code would be called.
const { section } = useParams();
useEffect(() => {
// the code to jump to the section
}, [section]);
I am not sure why can't you use document.dispatchEvent(new CustomEvent()) with an associated eventListener.
Also if it's a matter of scrolling you can scrollIntoView using refs

React state gets "corrupted"

I am working on a simple React.JS frontend piece.
I essentially have a browsing SPA for historical data. The design has a bunch of filters that I need to populate one at a time, starting the top one in my logic hierarchy.
I do something like:
componentWillMount() {
if (!this.props.advertisers) {
this.props.loadAdvertisers();
}
}
Once advertisers array has been loaded, I map it to options of a select component (using react-select), set the selection to the first item in the list and load the next list - campaigns.
As far as I understand, the best way to do this is still componentWillReceiveProps() and I am a little perplexed how this should be done differently, given that componentWillReceiveProps is being phased out.
My code looks like:
componentWillReceiveProps(nextProps) {
if (nextProps.advertisers && !this.props.advertisers) {
const advertiser = nextProps.advertisers[0];
this.setState({
advertiser: {
value: advertiser['id'],
label: advertiser['name']
}
});
nextProps.loadCampaigns(advertiser['id']);
}
// if campaigns list changed, reload the next filtered array
if (nextProps.campaigns && this.props.campaigns !== nextProps.campaigns) {
...
}
This worked fine, until I decided to add a loading indicator. I mapped state's loading property e.g. for campaigns it gets exposed via this.props.campaignsLoading then do:
return (this.props.campaignsLoading || this.props...Loading || ...) ?
<ProgressIndicator> : <MainContentPanel>
The problem is now, my state does not get set correctly inside componentWillReceiveProps().
The project is using #rematch and I initially tried this with #rematch/loading plugin and when the problem happened, thought the plugin does it wrong, somehow. Then, I mapped loading properties manually, and just added two more dispatches to manually set the loading flag.
All the props are being set/unset correctly, but my state is not being set and nothing works. Any suggestions?
When you do
if (nextProps.advertisers && !this.props.advertisers) {
You are not comparing the next and the previous props. "this.props.advertisers" is probably already set so you never go into the setState line. Although using componentWillReceiveProps is no longer the recommended way to go (You Probably Don't Need Derived State), what you probably want to do roughly is:
if (nextProps.advertisers && nextProps.advertisers !== !this.props.advertisers) {

Categories