I have a React Component using a hook to save the scroll position of the component when the component unmounts. This works great but fails when navigating from one set of data to another set of data without the component unmounting.
For instance, imagine the Slack Interface where there is a sidebar of message channels on the left and on the right is a list of messages (messageList). If you were to navigate between two channels, the messageList component would update with a new set of data for the messageList, but the component was never unmounted so scroll position never gets saved.
I came up with a solution that works, but also throws a warning.
My current useEffect hook for the component (stripped down) and the code that currently saves scroll position whenever the messageList ID changes:
// Component...
const usePrevious = (value) => {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
};
// Save scroll position when Component unmounts
useEffect(() => {
return () => {
setScrollOffset(parent._id, scrollPos.current);
};
}, []);
// Save scroll position when Parent ID changes
const oldParent = usePrevious(parent);
if (oldParent && parent._id !== oldParent._id) {
setScrollOffset(oldParent._id, list ? list.scrollTop : 0);
}
// ...Component
The error this throws is:
Warning: Cannot update a component from inside the function body of a different component.
And the line that is causing it is the setScrollOffset call inside of the last if block. I'm assuming that while this works it is not the way that I should be handling this sort of thing. What is a better way to handle saving scroll position when a specific prop on the component changes?
Add parent._id to the dependency array. Refactor your code to still cache the previous parent id, add that to the dependency, and move the conditional test inside the effect.
Cleaning up an effect
The clean-up function runs before the component is removed from the UI
to prevent memory leaks. Additionally, if a component renders multiple
times (as they typically do), the previous effect is cleaned up before
executing the next effect.
// Return previous parent id and cache current
const oldParent = usePrevious(parent);
// Save scroll position when Component unmounts or parent id changes
useEffect(() => {
if (oldParent && parent._id !== oldParent._id) {
setScrollOffset(oldParent._id, list ? list.scrollTop : 0);
}
return () => {
setScrollOffset(parent._id, scrollPos.current);
};
}, [parent._id, oldParent]);
If this does't quite fit the bill, then use two effects, one for the mount/unmount and the other for just updates on the parent id.
Thanks to the suggestions of #drew-reese, he got me pointed down the right path. After adopting his solution (which previously I could not get working properly), I was able to isolate my problem to usage with react-router. (connected-react-router in my case). The issue was that the component was rendering and firing the onScroll event handler and overwriting my scroll position before I could read it.
For me the solution ended up being to keep my existing useEffect hook but pull the scroll offset save out of it and into useLayoutEffect (Had to keep useEffect since there is other stuff in useEffect that I removed for the sake of keeping the sample code above lean). useLayoutEffect allowed me to read the current scroll position before the component fired the onScroll event which was ultimately overwriting my saved scroll position reference to 0.
This actually made my code much cleaner overall by removing the need for my usePrevious hook entirely. My useLayoutEffect hook now looks like this:
useLayoutEffect(() => {
return () => {
setScrollOffset(parent._id, scrollPos.current);
};
}, [parent._id]);
Related
I created a toast notification in React which is Toggleable via the timeNotification function. For testing purposes, I used a button in the same Component to toggle the notification. All the notification does is to set a CSS class to "active" and after 5 seconds remove it.
const timeNotification = () => {
setShowNotification(true);
setShowProgressBar(true)
setTimeout(() => {
setShowNotification(false)
}, 5000);
setTimeout(() => {
setShowProgressBar(false)
}, 5300)
}
my goal is to make this Component triggerable in my React app whenever I need it. For example, when I require the user to log in to my website and the backend server is not reachable it is supposed to use my Notification component to send an error to the user.
What is the best practice to do so?
The first idea was to simply pass a function from the child to the parent component which is a bad practice because as far as I know functionality should always be passed down and never upwards.
The second idea was to toggle the component via useEffect
useEffect(()=> {
timeNotification()
}, [])
This approach only works once. But after the Component is rendered for the first time, it just vanishes (gets moved out of the user's view). This approach would work if Id make the component un render after 5 seconds and rerender it as soon as a new error occurs which is also a bad practice.
I just found a solution, I still don't know if it's the best practice however it works. I did use the useEffect hook in the Child component.
useEffect(() => {
timeNotification();
}, [props.refresh])
its triggered by the Parent via props and useState
const [showLoginError, setShowLoginError] = useState<number>(0);
setShowLoginError(prev => prev + 1)
when ever the parent increases the showLoginError the Notification useEffect gets triggered
I know similar questions are bouncing around the web for quite some time but I still struggle to find a decision for my case.
Now I use functional React with hooks. What I need in this case is to set a state and AFTER the state was set THEN to start the next block of code, maybe like React with classes works:
this.setState({
someStateFlag: true
}, () => { // then:
this.someMethod(); // start this method AFTER someStateFlag was updated
});
Here I have created a playground sandbox that demonstrates the issue:
https://codesandbox.io/s/alertdialog-demo-material-ui-forked-6zss6q?file=/demo.tsx
Please push the button to get the confirmation dialog opened. Then confirm with "YES!" and notice the lag. This lag occurs because the loading data method starts before the close dialog flag in state was updated.
const fireTask = () => {
setOpen(false); // async
setResult(fetchHugeData()); // starts immediately
};
What I need to achieve is maybe something like using a promise:
const fireTask = () => {
setOpen(false).then(() => {
setResult(fetchHugeData());
});
};
Because the order in my case is important. I need to have dialog closed first (to avoid the lag) and then get the method fired.
And by the way, what would be your approach to implement a loading effect with MUI Backdrop and CircularProgress in this app?
The this.setState callback alternative for React hooks is basically the useEffect hook.
It is a "built-in" React hook which accepts a callback as it's first parameter and then runs it every time the value of any of it's dependencies changes.
The second argument for the hook is the array of dependencies.
Example:
import { useEffect } from 'react';
const fireTask = () => {
setOpen(false);
};
useEffect(() => {
if (open) {
return;
}
setResult(fetchHugeData());
}, [open]);
In other words, setResult would run every time the value of open changes,
and only after it has finished changing and a render has occurred.
We use a simple if statement to allow our code to run only when open is false.
Check the documentation for more info.
Here is how I managed to resolve the problem with additional dependency in state:
https://codesandbox.io/s/alertdialog-demo-material-ui-forked-gevciu?file=/demo.tsx
Thanks to all that helped.
Am getting this warning:
Can't perform a React state update on unmounted component. This is a no-op...
It results from a child component and I can't figure out how to make it go away.
Please note that I have read many other posts about why this happens, and understand the basic issue. However, most solutions suggest cancelling subscriptions in a componentWillUnmount style function (I'm using react hooks)
I don't know if this points to some larger fundamental misunderstanding I have of React,but here is essentially what i have:
import React, { useEffect, useRef } from 'react';
import Picker from 'emoji-picker-react';
const MyTextarea = (props) => {
const onClick = (event, emojiObject) => {
//do stuff...
}
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
});
useEffect(() => {
return () => {
console.log('will unmount');
isMountedRef.current = false;
}
});
return (
<div>
<textarea></textarea>
{ isMountedRef.current ? (
<Picker onEmojiClick={onClick}/>
):null
}
</div>
);
};
export default MyTextarea;
(tl;dr) Please note:
MyTextarea component has a parent component which is only rendered on a certain route.
Theres also a Menu component that once clicked, changes the route and depending on the situation will either show MyTextarea's parent component or show another component.
This warning happens once I click the Menu to switch off MyTextarea's parent component.
More Context
Other answers on StackOverflow suggest making changes to prevent state updates when a component isn't mounted. In my situation, I cannot do that because I didn't design the Picker component (rendered by MyTextarea). The Warning originates from this <Picker onEmojiClick={onClick}> line but I wouldn't want to modify this off-the-shelf component.
That's explains my attempt to either render the component or not based on the isMountedRef. However this doesn't work either. What happens is the component is either rendered if i set useRef(true), or it's never rendered at all if i set useRef(null) as many have suggested.
I'm not exactly sure what your problem actually is (is it that you can't get rid of the warning or that the <Picker> is either always rendering or never is), but I'll try to address all the problems I see.
Firstly, you shouldn't need to conditionally render the <Picker> depending on whether MyTextArea is mounted or not. Since components only render after mounting, the <Picker> will never render if the component it's in hasn't mounted.
That being said, if you still want to keep track of when the component is mounted, I'd suggest not using hooks, and using componentDidMount and componentWillUnmount with setState() instead. Not only will this make it easier to understand your component's lifecycle, but there are also some problems with the way you're using hooks.
Right now, your useRef(true) will set isMountedRef.current to true when the component is initialized, so it will be true even before its mounted. useRef() is not the same as componentDidMount().
Using 'useEffect()' to switch isMountedRef.current to true when the component is mounted won't work either. While it will fire when the component is mounted, useEffect() is for side effects, not state updates, so it doesn't trigger a re-render, which is why the component never renders when you set useRef(null).
Also, your useEffect() hook will fire every time your component updates, not just when it mounts, and your clean up function (the function being returned) will also fire on every update, not just when it unmounts. So on every update, isMountedRef.current will switch from true to false and back to true. However, none of this matters because the component won't re-render anyways (as explained above).
If you really do need to use useEffect() then you should combine it into one function and use it to update state so that it triggers a re-render:
const [isMounted, setIsMounted] = useState(false); // Create state variables
useEffect(() => {
setIsMounted(true); // The effect and clean up are in one function
return () => {
console.log('will unmount');
setIsMounted(false);
}
}, [] // This prevents firing on every update, w/o it you'll get an infinite loop
);
Lastly, from the code you shared, your component couldn't be causing the warning because there are no state updates anywhere in your code. You should check the picker's repo for issues.
Edit: Seems the warning is caused by your Picker package and there's already an issue for it https://github.com/ealush/emoji-picker-react/issues/142
I have a hook to save scroll value on window scroll.
(BTW, my state object is more complicated than this example code. That's why I'm using useReducer. I simplified the state for this question.)
Then when I attached that hook to App, everything inside useEffect runs every time I scroll.
I get that console.log(scroll) prints every time on scroll, but why is console.log("somethingelse") also prints every time on scroll?
I have other codes to put inside useEffect, so it's a huge problem if everything inside useEffect runs on scroll.
How do I make only useScroll() related code to run on scroll, and everything else runs only on re-render like useEffect supposed to do?
I made CodeSandbox example below.
function App() {
const scroll = useScroll();
useEffect(() => {
console.log(scroll);
console.log(
"this also runs everytime I scroll. How do I make this run only on re-render?"
);
});
return ( ... );
}
The way your code is currently written the useEffect code will be executed on each update. When scrolling the state is updated, so the effect will be executed.
You can pass props as the second variable to the useEffect to determine whether it should run or not.
So, if you create an effect like this it will run every time the scroll is updated
useEffect(() => {
console.log('This wil run on mount when scroll changes')
}, [scroll]) // Depend on scroll value, might have to be memoized depending on the exact content of the variable.
useEffect(() => {
console.log('This will only run once when the component is mounted')
}, []) // No dependencies.
useEffect(() => {
console.log('This will run on mount and when `someStateVar` is updated')
}, [someStateVar]) // Dependent on someStateVar, you can pass in multiple variables if you want.
Without passing the second argument, the effect will always run on every render. You can add multiple effects in a single component. So, you'll probably want to write one containing the stuff that needs to be executed when scroll updates and create one or more others that only run on mount of after some variable changes.
There is a concept of cleanup functions which can be attached to an useEffect hook. This cleanup function will be run once the rerender is complete. It's usually used in scenarios where you need to unsubscribe from a subscription once the render is complete. Below is the sample code from React website
useEffect(() => {
const subscription = props.source.subscribe();
return () => {
// Clean up the subscription
subscription.unsubscribe();
};
});
More details can be found in the blogpost
useEffect Hook
I have a component, which I'd like to keep the scroll position each time it re-renders.
Therefore I have created a useState for it's position. Inside the useEffect, I've created an intervall, which will track the current position every x seconds and set it using setState.
It seems to be working fine to this point, because when I console log the state (scrollTop) inside the useEffect, I get the updated numbers.
I keep track of the element by setting it as a ref with useRef.
const [scrollState, setScrollState] = useState(0);
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
console.log(scrollState);
const captureElementScroll = setInterval(() => {
if (element.current) {
const scrollTop = _.get(element.current, "scrollY");
setScrollState(scrollTop);
}
}, 2000);
if (element.current && element.current) {
element.current.scrollTo({
behavior: "smooth",
top: scrollState
});
}
return () => {
clearInterval(captureElementScroll);
};
}, [scrollState]);
Now the problem is, each time this component gets re-rendered, the current state is set back to zero.
Maybe a problem with the parent components? I'm out of ideas. Am I doing it the correct way?
Now looking back to this issue (I have discontinued what I was working on ), I'm pretty sure that I captured the scroll position on the wrong level.
Like I guessed already, the problem was that the Parent component got re-rendered and therefore the state got lost.
Maybe I'll work on something similar at some point. If so I'll write a more detailed answer.