So I've been trying out React Hooks for a change, but there's something I haven't been able to understand, and it's using multiple states in a functional component and interacting between them. Say I want to get the percentage of the window's scroll position and its height, & then display it like so:
import React, { useState, useLayoutEffect } from 'react'
const Page = () => {
const [scrollPos, setScrollPos] = useState(window.pageYOffset || document.documentElement.scrollTop)
const [windowSize, setWindowSize] = useState(window.innerHeight)
const [percent, setPercent] = useState(0)
useLayoutEffect(() => {
const handleScroll = () => {
const y = window.pageYOffset || document.documentElement.scrollTop
setScrollPos(y)
// windowSize is always its initial value, not its latest
console.log("handleScroll: " + y + " / " + windowSize + " = " + y / windowSize)
setPercent(y / windowSize)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useLayoutEffect(() => {
const handleResize = () => {
const height = window.innerHeight
setWindowSize(height)
// scrollPos is always its initial value, not its latest
console.log("handleScroll: " + scrollPos + " / " + height + " = " + scrollPos / height)
setPercent(scrollPos / windowSize)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<div>
{/* Calculates percentage correctly upon scrolling and resizing */}
<p>{scrollPos} / {windowSize} = {scrollPos / windowSize}</p>
{/* Incorrect once I scroll after resizing and vice versa */}
<p>!= {percent}</p>
</div>
)
}
export default Page
If I run this, the console won't display the latest values of scrollPos and windowSize, but instead the initial ones; while rendering them does show their latest values. And percent gets mixed up with that as it grabs the initial value of one of them upon resizing or scrolling the window.
I think this is one of those things that's caused because of it being asynchronous, but it'd be nice to get a clearer answer on this. How would one be able to work with multiple "local" states, or is it just better to make one single merged state for cases like these?
Related
I want to be able to listen to scroll event to get the current value, and if the value reaches a certain threshold render a div the current logic works well with useState but it is rendering every render.
useRef however doesn't seem do be doing what is should, is there any solution to this ? will callback solve this ? if possible could you refactor to a better logic.
const scrollRef = useRef<number>(0);
useEffect(() => {
const listenToScroll = () => {
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const scrolled = winScroll / height;
scrollRef.current = scrolled;
};
const fn = window.addEventListener('scroll', listenToScroll);
return fn;
}, []);
I am trying to set up an image carousel that loops through 3 images when you mouseover a div. I'm having trouble trying to figure out how to reset the loop after it reaches the third image. I need to reset the setInterval so it starts again and continuously loops through the images when you are hovering over the div. Then when you mouseout of the div, the loop needs to stop and reset to the initial state of 0. Here is the Code Sandbox:
https://codesandbox.io/s/pedantic-lake-wn3s7
import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";
export default function App() {
let timer;
const [count, setCount] = useState(0);
const updateCount = () => {
timer = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
if (count === 3) clearInterval(timer);
};
const origCount = () => {
clearInterval(timer);
setCount((count) => 0);
};
return (
<div className="App">
<div className="title">Image Rotate</div>
<div onMouseOver={updateCount} onMouseOut={origCount}>
<img src={images[count].source} alt={images.name} />
<p>count is: {count}</p>
</div>
</div>
);
}
Anything involving timers/intervals is an excellent candidate for useEffect, because we can easily register a clear action in the same place that we set the timer using effects with cleanup. This avoids the common pitfalls of forgetting to clear an interval, e.g. when the component unmounts, or losing track of interval handles. Try something like the following:
import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";
export default function App() {
const [count, setCount] = useState(0);
const [mousedOver, setMousedOver] = useState(false);
useEffect(() => {
// set an interval timer if we are currently moused over
if (mousedOver) {
const timer = setInterval(() => {
// cycle prevCount using mod instead of checking for hard-coded length
setCount((prevCount) => (prevCount + 1) % images.length);
}, 1000);
// automatically clear timer the next time this effect is fired or
// the component is unmounted
return () => clearInterval(timer);
} else {
// otherwise (not moused over), reset the counter
setCount(0);
}
// the dependency on mousedOver means that this effect is fired
// every time mousedOver changes
}, [mousedOver]);
return (
<div className="App">
<div className="title">Image Rotate</div>
<div
// just set mousedOver here instead of calling update/origCount
onMouseOver={() => setMousedOver(true)}
onMouseOut={() => setMousedOver(false)}
>
<img src={images[count].source} alt={images.name} />
<p>count is: {count}</p>
</div>
</div>
);
}
As to why your code didn't work, a few things:
You meant to say if (count === 2) ..., not count === 3. Even better would be to use the length of the images array instead of hardcoding it
Moreover, the value of count was stale inside of the closure, i.e. after you updated it using setCount, the old value of count was still captured inside of updateCount. This is actually the reason to use functional state updates, which you did when you said e.g. setCount((prevCount) => prevCount + 1)
You would have needed to loop the count inside the interval, not clear the interval on mouse over. If you think through the logic of it carefully, this should hopefully be obvious
In general in react, using a function local variable like timer is not going to do what you expect. Always use state and effects, and in rarer cases (not this one), some of the other hooks like refs
I believe that setInterval does not work well with function components. Since callback accesses variables through closure, it's really easy to shoot own foot and either get timer callback referring to stale values or even have multiple intervals running concurrently. Not telling you cannot overcome that, but using setTimeout is much much much easier to use
useEffect(() => {
if(state === 3) return;
const timerId = setTimeout(() => setState(old => old + 1), 5000);
return () => clearTimeout(timerId);
}, [state]);
Maybe in this particular case cleanup(clearTimeout) is not required, but for example if user is able to switch images manually, we'd like to delay next auto-change.
The timer reference is reset each render cycle, store it in a React ref so it persists.
The initial count state is closed over in interval callback scope.
There are only 3 images so the last slide will be index 2, not 3. You should compare against the length of the array instead of hard coding it.
You can just compute the image index by taking the modulus of count state by the array length.
Code:
export default function App() {
const timerRef = useRef();
const [count, setCount] = useState(0);
// clear any running intervals when unmounting
useEffect(() => () => clearInterval(timerRef.current), []);
const updateCount = () => {
timerRef.current = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
};
const origCount = () => {
clearInterval(timerRef.current);
setCount(0);
};
return (
<div className="App">
<div className="title">Image Rotate</div>
<div onMouseOver={updateCount} onMouseOut={origCount}>
<img
src={images[count % images.length].source} // <-- computed index to cycle
alt={images.name}
/>
<p>count is: {count}</p>
</div>
</div>
);
}
Your setCount should use a condition to check to see if it should go back to the start:
setCount((prevCount) => prevCount === images.length - 1 ? 0 : prevCount + 1);
This will do setCount(0) if we're on the last image—otherwise, it will do setCount(prevCount + 1).
A faster (and potentially more readable) way of doing this would be:
setCount((prevCount) => (prevCount + 1) % images.length);
I encountered the strangest problem today. My code (much has been stripped out) consists of basically the following:
const Element: React.FC<any> = (props: any) => {
const scrollRef = React.useRef<any>(null);
.
.
.
const rescrollTrigger = () => {
setTimeout(() => { /* Retry for 10 seconds to scroll the content to match the stored value, unless the user has scrolled since then */
const cb = (tries: number) => {
if (tries > 100)
return;
if (!scrollRef.current) {
setTimeout(cb, 100, [Number(tries) + 1]);
return;
}
scrollRef.current.getScrollElement().then((element: any) => {
var scrollHeight: any = window.sessionStorage.getItem(thisPath + "/scrollheight");
var scrollTop: any = window.sessionStorage.getItem(thisPath + "/scrolltop");
if (!scrollHeight || !scrollTop)
return;
scrollHeight = +scrollHeight;
scrollTop = +scrollTop;
if (element.scrollHeight == scrollHeight)
scrollRef.current.scrollToPoint(0, scrollTop);
else
setTimeout(cb, 100, [Number(tries) + 1]);
}).catch(() => {
setTimeout(cb, 100, [Number(tries) + 1]);
})
};
window.requestAnimationFrame(() => {
cb(1);
});
});
};
const saveScrollPosition = () => {
console.log("saveScrollPosition", scrollRef.current);
try {
scrollRef.current.getScrollElement().then((element: any) => {
console.log("Saving scroll position");
window.sessionStorage.setItem(thisPath + "/scrolltop", String(element.scrollTop));
window.sessionStorage.setItem(thisPath + "/scrollheight", String(element.scrollHeight));
});
} catch (err) {console.log("Error saving scroll position", err)}
};
return (
<IonPage>
<IonContent fullscreen ref={scrollRef} scrollEvents={true} onIonScrollEnd={e => saveScrollPosition()}>
.
.
.
}
I'm just trying to create a ref (in a hook) to the content instance so I can save and reset the scroll position as needed.
This code was working fine, but for some reason stopped triggering any scroll events if a ref is set on the content element. I've tried onIonScroll and onIonScrollEnd. Neither work if a ref is set. Removing the ref={scrollRef} starts triggering the scroll events. I'm using Ionic 6.12.2 and just recently upgraded from a prior version (I don't know what it was).
So my question is: Is this a bug in Ionic? Or if not, what should be done to fix the code? I've tried not setting a ref and just using the target returned from the scroll event, which allows me to save the position, but it doesn't preserve a reference to be able to reset the scroll position later.
This looks like an Ionic bug. Ionic 6.12.3 allows the event to fire with a ref and the behavior is not reproducible.
I am rendering photos from unsplash api. And I am keeping the index of the photos to be used in the lightbox, after the initial render state of imageindex goes back to 0, how can I retain its value?
I will show some code
const ImageList = ({ image, isLoaded }) => {
const [imageIndex, setImageIndex] = useState(0);
const [isOpen, setIsOpen] = useState('false');
const onClickHandler = (e) => {
setIsOpen(true);
setImageIndex(e.target.id);
};
const imgs = image.map((img, index) => (
<img
id={index}
key={img.id}
src={img.urls.small}
onClick={onClickHandler}
if (isOpen === true) {
return (
<Lightbox
onCloseRequest={() => setIsOpen(false)}
mainSrc={image[imageIndex].urls.regular}
onMoveNextRequest={() => setImageIndex((imageIndex + 1) % image.length)}
onMovePrevRequest={() => setImageIndex((imageIndex + image.length - 1) % image.length)}
nextSrc={image[(imageIndex + 1) % image.length].urls.regular}
prevSrc={image[(imageIndex + image.length - 1) % image.length].urls.regular}
/>
after the initial render state, imageIndex goes back to 0.
That makes sense, the initial render would use whatever you set as the default value. You can use something like local storage to help you keep track of the index of the last used item. It's a bit primitive, but until you integrate something like Node/MongoDB for database collections, this will be perfect.
In your component, import useEffect() from React. This hook lets us execute some logic any time the state-index value changes, or anything else you might have in mind.
import React, { useEffect } from "react"
Then inside your component, define two useEffect() blocks.
Getting last used index from localStorage on intitial load:
useEffect(() => {
const lastIndex = localStorage.getItem("index", imageIndex)
setImageIndex(imageIndex)
}, []) //set as an empty array so it will only execute once.
Saving index to localStorage on change:
useEffect(() => {
localStorage.setItem("index", imageIndex)
}, [imageIndex]) //define values to subscribe to here. Will execute anytime value changes.
I'm trying to visualise 500+ vehicles using leaflet. When the position of a marker (vehicle) changes, it will move slowly to reach the destination (using requestAnimationFrame and leaflet's 'native' setLatLng since I don't want to update the state directly). It works well, but I also have a click listener on each marker and notice that it never fires. I soon realised that leaflet has been updating the marker continuously (the DOM element keeps blinking in the inspector). I attempted to log something to see if the component actually re-renders, but it doesn't. Seems like leaflet is messing with the DOM element under the hood.
const Marker = React.memo(function Marker({ plate, coors, prevCoors }) {
const markerRef = React.useRef();
const [activeVehicle, handleActiveVehicleUpdate] = useActiveVehicle();
const heading = prevCoors != null ? GeoHelpers.computeHeading(prevCoors, coors) : 0;
React.useEffect(() => {
if (prevCoors == null) return;
const [prevLat, prevLong] = prevCoors;
const [lat, long] = coors;
let animationStartTime;
const animateMarker = timestamp => {
if (animationStartTime == null) animationStartTime = timestamp;
const progress = (timestamp - animationStartTime) / 5000;
if (progress > 1) return;
const currLat = prevLat + (lat - prevLat) * progress;
const currLong = prevLong + (long - prevLong) * progress;
const position = new LatLng(currLat, currLong);
markerRef.current.leafletElement.setLatLng(position);
requestAnimationFrame(animateMarker);
};
const animationFrame = requestAnimationFrame(animateMarker);
// eslint-disable-next-line consistent-return
return () => cancelAnimationFrame(animationFrame);
}, [coors, prevCoors]);
React.useEffect(() => {
if (plate === '60C23403') console.log('re-render!');
// eslint-disable-next-line
});
return (
<LeafletMarker
icon={createIcon(plate === activeVehicle, heading)}
position={prevCoors != null ? prevCoors : coors}
onClick={handleActiveVehicleUpdate(plate, coors)}
ref={markerRef}
>
<Tooltip>{plate}</Tooltip>
</LeafletMarker>
);
});
How do I prevent this behaviour from leaflet? Any idea is appreciated. Thanks in advance :)