Detect scroll direction in React js - javascript

I'm trying to detect if the scroll event is up or down but I can't find the solution.
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
const Navbar = ({ className }) => {
const [y, setY] = useState(0);
const handleNavigation = (e) => {
const window = e.currentTarget;
if (y > window.scrollY) {
console.log("scrolling up");
} else if (y < window.scrollY) {
console.log("scrolling down");
}
setY(window.scrollY);
};
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, []);
return (
<nav className={className}>
<p>
<i className="fas fa-pizza-slice"></i>Food finder
</p>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
</ul>
</nav>
);
};
export default Navbar;
Basically it's always detected as "down" because y in handleNavigation is always 0. If i check the state in DevTool the y state updates but in the handleNavigation doesn't.
Any suggestions what am I doing wrong?
Thanks for your help

TLDR;
Since this answer has drawn some attention, I've just developed an npm package based on it to allow everyone to use it as an independent package/library in their projects. Therefore if you want something to work with right away, you can add it to your project:
Demo
npm i #smakss/react-scroll-direction
or
yarn add #smakss/react-scroll-direction
Read more here.
The answer and its description
This is because you defined a useEffect() without any dependencies, so your useEffect() will only run once, and it never calls handleNavigation() on y changes. To fix this, you need to add y to your dependency array to tell your useEffect() to run whenever the y value changes. Then it would be best if you had another change to take effect in your code, where you are trying to initialize your y with window.scrollY, so you should do this in your useState() like:
const [y, setY] = useState(window.scrollY);
useEffect(() => {
window.addEventListener("scroll", (e) => handleNavigation(e));
return () => { // return a cleanup function to unregister our function since it's going to run multiple times
window.removeEventListener("scroll", (e) => handleNavigation(e));
};
}, [y]);
If, for some reason, window was unavailable there or you don't want to do it here, you can do it in two separate useEffect()s.
So your useEffect() should be like this:
useEffect(() => {
setY(window.scrollY);
}, []);
useEffect(() => {
window.addEventListener("scroll", (e) => handleNavigation(e));
return () => { // return a cleanup function to unregister our function since its gonna run multiple times
window.removeEventListener("scroll", (e) => handleNavigation(e));
};
}, [y]);
UPDATE (Working Solutions)
After implementing this solution on my own. I found out some notes should be applied to this solution. So since the handleNavigation() will change y value directly, we can ignore the y as our dependency and then add handleNavigation() as a dependency to our useEffect(), then due to this change we should optimize handleNavigation(), so we should use useCallback() for it. Then the final result will be something like this:
const [y, setY] = useState(window.scrollY);
const handleNavigation = useCallback(
e => {
const window = e.currentTarget;
if (y > window.scrollY) {
console.log("scrolling up");
} else if (y < window.scrollY) {
console.log("scrolling down");
}
setY(window.scrollY);
}, [y]
);
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", handleNavigation);
return () => {
window.removeEventListener("scroll", handleNavigation);
};
}, [handleNavigation]);
After a comment from #RezaSam, I noticed a teeny tiny mistake in the memoized version. Where I call handleNavigation within another arrow function, I found out (via the browser dev tool, event listeners tab) that each component rerender will register a new event to the window, so it might ruin the whole thing.
Working demo:
Final Optimized Solution
After all, I ended up that memoization, in this case, will help us to register a single event, to recognize scroll direction, but it is not fully optimized in printing the consoles because we are consoling inside the handleNavigation function and there is no other way around to print the desired consoles in the current implementation.
So, I realized there is a better way of storing the last page scroll position each time we want to check a new status. Also, to get rid of a vast amount of consoling scrolling up and scrolling down, we should define a threshold (Use debounce approach) to trigger the scroll event change. So I just searched through the web a bit and ended up with this gist, which was very useful. Then with the inspiration of it, I implement a simpler version.
This is how it looks:
const [scrollDir, setScrollDir] = useState("scrolling down");
useEffect(() => {
const threshold = 0;
let lastScrollY = window.pageYOffset;
let ticking = false;
const updateScrollDir = () => {
const scrollY = window.pageYOffset;
if (Math.abs(scrollY - lastScrollY) < threshold) {
ticking = false;
return;
}
setScrollDir(scrollY > lastScrollY ? "scrolling down" : "scrolling up");
lastScrollY = scrollY > 0 ? scrollY : 0;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDir);
ticking = true;
}
};
window.addEventListener("scroll", onScroll);
console.log(scrollDir);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollDir]);
How it works?
I will go from top to bottom and explain each code block.
So I just defined a threshold point with the initial value of 0; then, whenever the scroll goes up or down, it will make a new calculation. You can increase it if you don't want to immediately calculate a new page offset.
Then, instead of scrollY, I decide to use pageYOffset, which is more reliable in cross-browsing.
In the updateScrollDir function, we will check if the threshold is met; then, if it is completed, I will specify the scroll direction based on the current and previous page offset.
The most crucial part of it is the onScroll function. I just used requestAnimationFrame to make sure that we are calculating the new offset after the page got rendered wholly after scrolling. And then, with the ticking flag, we will ensure we are just running our event listener callback once in each requestAnimationFrame.
At last, we defined our listener and our cleanup function.
Then, the scrollDir state will contain the actual scroll direction.
Working demo:

Just wanted to come in with a neat solution, it's quite similar to habbahans but looks a little neater in my opinion.
let oldScrollY = 0;
const [direction, setDirection] = useState('up');
const controlDirection = () => {
if(window.scrollY > oldScrollY) {
setDirection('down');
} else {
setDirection('up');
}
oldScrollY = window.scrollY;
}
useEffect(() => {
window.addEventListener('scroll', controlDirection);
return () => {
window.removeEventListener('scroll', controlDirection);
};
},[]);
Here you can just access the hidden state to do what you wish with in your code.

Try this package - react-use-scroll-direction
import { useScrollDirection } from 'react-use-scroll-direction'
export const MyComponent = () => {
const { isScrollingDown } = useScrollDirection()
return (
<div>
{isScrollingDown ? 'Scrolling down' : 'scrolling up'}
</div>
)
}

Most of the answers seems a bit over-engineered in my opinion.
Here's what I use in my nextjs projects:
function useVerticalScrollDirection() {
const [direction, setDirection] = useState('up');
let prevScrollY = 0;
useEffect(() => {
// Using lodash, we set a throttle to the scroll event
// making it not fire more than once every 500 ms.
window.onscroll = throttle(() => {
// This value keeps the latest scrollY position
const { scrollY } = window;
// Checks if previous scrollY is less than latest scrollY
// If true, we are scrolling downwards, else scrollig upwards
const direction = prevScrollY < scrollY ? 'down' : 'up';
// Updates the previous scroll variable AFTER the direction is set.
// The order of events is key to making this work, as assigning
// the previous scroll before checking the direction will result
// in the direction always being 'up'.
prevScrollY = scrollY;
// Set the state to trigger re-rendering
setDirection(direction);
}, 500);
return () => {
// Remove scroll event on unmount
window.onscroll = null;
};
}, []);
return direction;
}
Then I use it my component like this:
function MyComponent() {
const verticalScrollDirection = useVerticalScrollDirection();
{....}
}

I was looking around and couldn't find a simple solution, so I looked into the event itself and there exists a "deltaY" which makes everything way simpler (no need to keep state of the last scroll value). The "deltaY" value shows the change in "y" that the event had (a positive deltaY means it was a scroll down event, and a negative deltaY means it was a scroll up event).
Here's how it works:
componentDidMount() {
window.addEventListener('scroll', e => this.handleNavigation(e));
}
handleNavigation = (e) => {
if (e.deltaY > 0) {
console.log("scrolling down");
} else if (e.deltaY < 0) {
console.log("scrolling up");
}
};

I found this neat & simple solution just few lines of codes
<div onWheel={ event => {
if (event.nativeEvent.wheelDelta > 0) {
console.log('scroll up');
} else {
console.log('scroll down');
}
}}
>
scroll on me!
</div>
onWheel synthetic event returns an event object having an attribute named nativeEvent containing the original event information. wheelDelta is used to detect the direction even if there is no effective scroll (overflow:hidden).
This is original source -> http://blog.jonathanargentiero.com/detect-scroll-direction-on-react/

Here's my React hook solution, useScrollDirection:
import { useEffect, useState } from 'react'
export type ScrollDirection = '' | 'up' | 'down'
type HistoryItem = { y: number; t: number }
const historyLength = 32 // Ticks to keep in history.
const historyMaxAge = 512 // History data time-to-live (ms).
const thresholdPixels = 64 // Ignore moves smaller than this.
let lastEvent: Event
let frameRequested: Boolean = false
let history: HistoryItem[] = Array(historyLength)
let pivot: HistoryItem = { t: 0, y: 0 }
export function useScrollDirection({
scrollingElement,
}: { scrollingElement?: HTMLElement | null } = {}): ScrollDirection {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('')
useEffect(() => {
const element: Element | null =
scrollingElement !== undefined ? scrollingElement : document.scrollingElement
if (!element) return
const tick = () => {
if (!lastEvent) return
frameRequested = false
let y = element.scrollTop
const t = lastEvent.timeStamp
const furthest = scrollDirection === 'down' ? Math.max : Math.min
// Apply bounds to handle rubber banding
const yMax = element.scrollHeight - element.clientHeight
y = Math.max(0, y)
y = Math.min(yMax, y)
// Update history
history.unshift({ t, y })
history.pop()
// Are we continuing in the same direction?
if (y === furthest(pivot.y, y)) {
// Update "high-water mark" for current direction
pivot = { t, y }
return
}
// else we have backed off high-water mark
// Apply max age to find current reference point
const cutoffTime = t - historyMaxAge
if (cutoffTime > pivot.t) {
pivot.y = y
history.filter(Boolean).forEach(({ y, t }) => {
if (t > cutoffTime) pivot.y = furthest(pivot.y, y)
})
}
// Have we exceeded threshold?
if (Math.abs(y - pivot.y) > thresholdPixels) {
pivot = { t, y }
setScrollDirection(scrollDirection === 'down' ? 'up' : 'down')
}
}
const onScroll = (event: Event) => {
lastEvent = event
if (!frameRequested) {
requestAnimationFrame(tick)
frameRequested = true
}
}
element.addEventListener('scroll', onScroll)
return () => element.removeEventListener('scroll', onScroll)
}, [scrollDirection, scrollingElement])
return scrollDirection
}
Usage:
const [scrollingElement, setScrollingElement] = useState<HTMLElement | null>(null)
const ref = useCallback(node => setScrollingElement(node), [setScrollingElement])
const scrollDirection = useScrollDirection({ scrollingElement })
<ScrollingContainer {...{ ref }}>
<Header {...{ scrollDirection }}>
</ScrollingContainer>
Based on https://github.com/pwfisher/scroll-intent and https://github.com/dollarshaveclub/scrolldir. Also ported to React here: https://github.com/AnakinYuen/scroll-direction.

Here is my solution that extends some of the ideas found here. It fires only once every direction change and adds some params to fine-tune the hook call
const useScrollDirection = ({
ref,
threshold,
debounce,
scrollHeightThreshold,
}) => {
threshold = threshold || 10;
debounce = debounce || 10;
scrollHeightThreshold = scrollHeightThreshold || 0;
const [scrollDir, setScrollDir] = useState(null);
const debouncedSetScrollDir = _.debounce(setScrollDir, debounce);
useEffect(() => {
let lastScrollY = ref?.current?.scrollTop;
let lastScrollDir;
let ticking = false;
const hasScrollHeightThreshold =
ref?.current?.scrollHeight - ref?.current?.clientHeight >
scrollHeightThreshold;
const updateScrollDir = () => {
const scrollY = ref?.current?.scrollTop;
if (
Math.abs(scrollY - lastScrollY) < threshold ||
!hasScrollHeightThreshold
) {
ticking = false;
return;
}
const newScroll = scrollY > lastScrollY ? 'down' : 'up';
if (newScroll !== lastScrollDir) {
debouncedSetScrollDir(newScroll);
}
lastScrollY = scrollY > 0 ? scrollY : 0;
lastScrollDir = newScroll;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDir);
ticking = true;
}
};
ref?.current?.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return scrollDir;
};
Codepen demo

I have been searches this things for an hours. But no one solution work for me, so i write like this and worked for my next.js project.
const [currentScroll, setCurrentScroll] = useState(0)
const [lastScroll, setLastScroll] = useState(0)
const [scrollUp, setScrollUp] = useState(false)
useEffect(()=>{
function handleScroll(){
setCurrentScroll(scrollY)
// check if current scroll
// more than last scroll
if(currentScroll>lastScroll){
setScrollUp('Down')
} else {
setScrollUp('Up')
}
}
// change the last scroll
setLastScroll(scrollY)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
// this needed to change last scroll
// if currentscroll has change
},[currentScroll]) }

useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, []);
Because you pass an empty array into useEffect, so this hook only runs once and does not render every time y changes.
To change the value of y, we need to re-render when scrolling, so we need to pass a state into this array. In here, we see that window.scrollY always changes once you scroll, so [window.scrollY] can be the best solution to resolve your problem.
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, [window.scrollY]);
CopePen Demo
Your problem relates to using the dependency in useEffect, you can reference it in this link from the React documentation (note part): useEffect Dependency

Related

scrollBy execution in recursive function in ReactJs doesn't work as expected

As a part of my journey of creating own realization of drag&drop I faced with the problem of autoscrolling while dragging item.
For now I just try to realize primitive autoscrolling: when mouse enters the bottom-trigger area the page should start scrolling slowly, but it's scrolled by 10px and stopped, while I leave trigger area. What's the couse of these problem, does scrollBy rewrites each other till the end of recursion? If yes, how can I avoid it?
Code example(High order component AutoScroll):
export interface IAutoScrollProps {
children: ReactNode;
}
const AutoScroll = ({ children }: IAutoScrollProps) => {
const bottomTriggerClass = `${styles['bottom-trigger']} ${styles['trigger']}
${styles['horizontal']}`;
const overBottomTrigger = useRef(false);
const scrollBottom: () => void | NodeJS.Timeout = useCallback(() => {
window.scrollBy(0, 10);
if (!overBottomTrigger.current) return;
if (document.body.scrollHeight - window.innerHeight - window.scrollY < 20) return;
return setTimeout(scrollBottom, 0);
}, []);
const onBottomTriggerMouseEnter: (e: React.MouseEvent) => void = useCallback(
(e) => {
console.log('enter');
overBottomTrigger.current = true;
scrollBottom();
},[scrollBottom]);
const onBottomTriggerMouseLeave: (e: React.MouseEvent) => void = useCallback((e) => {
console.log('leave');
overBottomTrigger.current = false;
}, []);
return (
<div>
<section className="autoScroll-container p-relative">
{children}
</section>
<span
className={bottomTriggerClass}
onMouseEnter={onBottomTriggerMouseEnter}
onMouseLeave={onBottomTriggerMouseLeave}
></span>
</div>
);
};
export default AutoScroll;
The problem is likely caused by the use of setTimeout in your scrollBottom() function. setTimeout is asynchronous, meaning that it will not execute immediately, which means that your scrollBy() calls may be overwriting each other and not resulting in the desired effect.
To avoid this issue, you should use requestAnimationFrame() instead of setTimeout. This will ensure that the scrollBy() calls are executed in the same frame, and should result in the desired autoscrolling behavior.
Try this instead :
const scrollBottom: () => void | NodeJS.Timeout = useCallback(() => {
window.scrollBy(0, 10);
if (!overBottomTrigger.current) return;
if (document.body.scrollHeight - window.innerHeight - window.scrollY < 20) return;
requestAnimationFrame(scrollBottom);
}, []);
That's how I recreate scrollBottom function to make it work as expected:
const scrollBottom: () => void = useCallback(() => {
if (!overBottomTrigger.current) return;
if (document.body.scrollHeight - window.innerHeight - window.scrollY <= 0)
return;
window.scrollBy(0, 1);
window.onscroll = () => {
scrollBottom();
};
}, []);
I understand, that listeners creates macrotasks, as setTimeout, but it's still unclear why the previous variant doesn't work. Does scrollBy function accumulates their executions, to execute it once?

How to set state with a setInterval() when a button is clicked?

I'm trying to update the count of activeIndex within the setInterval when the checkbox is ticked. handleNext() and handlePrevious() are working fine when buttons are clicked but when the checkbox is checked the value of activeIndex is not getting updated in handleNext() but it's getting updated on the screen, so there is no condition check for activeIndex and it goes beyond 3.
import { useState } from "react";
import "./styles.css";
export default function App() {
const [slideTimer, setSlideTimer] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const slideDuration = 1000;
const handleNext = () => {
if ((activeIndex) >= 3) {
setActiveIndex(0);
} else {
setActiveIndex(prev => prev + 1);
}
};
const handlePrev = () => {
if (activeIndex <= 0) {
setActiveIndex(3);
} else {
setActiveIndex((prev) => prev - 1);
}
};
const toggleSlider = () => {
if (slideTimer) {
clearInterval(slideTimer);
setSlideTimer(null);
return;
}
setSlideTimer(setInterval(handleNext, slideDuration));
};
return (
<div className="App">
<h1>{activeIndex}</h1>
<button onClick={() => handleNext()}>Next</button>
<button onClick={() => handlePrev()}>Previous</button>
<input onClick={() => toggleSlider()} type="checkbox" />
</div>
);
}
I have tried putting the code for toggleSlider inside useEffect() but that too is not working.
Your problem is that when handleNext gets defined (which occurs on every rerender), it only knows about the variables/state in its surrounding scope at the time that it's defined. As a result, when you queue an interval with:
setInterval(handleNext, slideDuration)
You will be executing the handleNext function that only knows about the component's state at that time. When your component eventually rerenders and sets a new value for activeIndex, your interval will still be exeucting the "old" handleNext function defined in the previous render that doesn't know about the newly updated state. One option to resolve this issue is to make the hanldeNext function not rely on state obtained from its outer scope, but instead, use the state setter function to get the current value:
const handleNext = () => {
setActiveIndex(currIndex => (currIndex + 1) % 4);
};
Above I've used % to cycle the index back to 0, but you very well can also use your if-statement, where you return 0 if currIndex >= 3, or return currIndex + 1 in your else.
I would also recommend that you remove the slideTimer. This state value isn't being used to describe your UI (you can see that as you're not using slideTimer within your returned JSX). In this case, you're better off using a ref to store your interval id:
const slideTimerRef = useRef(0); // instead of the `slideTimer` state
...
clearInterval(slideTimerRef.current);
slideTimerRef.current = null;
...
slideTimerRef.current = setInterval(handleNext, slideDuration);

infinite requestAnimationFrame Loop in next.js

I am trying to recreate this app in next.js https://codelabs.developers.google.com/tensorflowjs-transfer-learning-teachable-machine#0
To start the data collection, I added a onMouseDown MouseEvent on the button which, triggers this code:
const handleGatherDataForClass: MouseEventHandler = (e) => {
let classNumber = parseInt(e.target.getAttribute('data-1hot'));
console.log('inside EventHandler:', { classNumber });
let state = gatherDataState === STOP_DATA_GATHER ? classNumber : STOP_DATA_GATHER;
console.log({ state });
setGatherDataState(state);
};
Where
classNumber = positive integer
STOP_DATA_GATHER = -1
gatherDataState = -1 (default)
Here is the original code snippet from the working app: https://codelabs.developers.google.com/tensorflowjs-transfer-learning-teachable-machine#11
After the state of the gatherDataState variable changes, the useEffect hook should run the dataGatherLoop function, which takes frames from the video stream and converts it into tensors:
useEffect(() => {
dataGatherLoop();
}, [gatherDataState]);
function dataGatherLoop() {
console.log('inside Loop: ', {
gatherDataState
});
if (videoPlaying && gatherDataState !== STOP_DATA_GATHER) {
let imageFeatures = tf.tidy(function() {
let videoFrameAsTensor = tf.browser.fromPixels(VIDEO);
let resizedTensorFrame = tf.image.resizeBilinear(
videoFrameAsTensor, [MOBILE_NET_INPUT_HEIGHT, MOBILE_NET_INPUT_WIDTH],
true
);
let normalizedTensorFrame = resizedTensorFrame.div(255);
return mobilenet.predict(normalizedTensorFrame.expandDims()).squeeze();
});
imageFeatures.print();
setTrainData((prev) => ({
trainX: [...prev.trainX, imageFeatures],
trainY: [...prev.trainY, gatherDataState],
}));
// Intialize array index element if currently undefined.
let newCount = [...examplesCount];
if (examplesCount[gatherDataState] === undefined) {
newCount[gatherDataState] = 1;
setExamplesCount(newCount);
} else {
newCount[gatherDataState]++;
setExamplesCount(newCount);
}
window.requestAnimationFrame(dataGatherLoop);
}
}
This loop runs, as long as the gatherDataState variable is a positive integer (not equal to -1)
After the mouse button is released, an onMouseUp event is triggered which runs the same handleGatherDataForClass function as the onMouseDown event. This should change the state back to -1 and therefore stop the Loop.
Problem:
Even though the state is changing to -1 after the onMouseUp event is triggered, the gatherDataState ends up being a positive integer every time.. Therefore the loop is not stopping. (there is NO setGatherDataState function anywhere else in the code)
I tried:
writing the gatherDataLoop function inside the handleGatherDataForClass event handler and passing the gatherDataState variable as an argument
using a global variable for gatherDataState instead of a react state to save the current gatherDataState
canceling the requestAnimationFrame loop with the cancelAnimationFrame function (saving the id globally and as state)
I am not exactly sure why, but
using useRef to store the values being used inside the requestAnimationFrame loop, as well as the id for canceling the loop and
taking the loop out of the useEffect Hook and writing it inside the ClickEventhandler
worked for me.
Here is the code:
const handleGatherDataForClass: MouseEventHandler = (e) => {
let classNumber = parseInt(e.target.getAttribute('data-1hot'));
gatherDataStateRef.current = classNumber;
isCollectingRef.current = !isCollectingRef.current;
if (isCollectingRef.current) {
collectRequestRef.current = requestAnimationFrame(dataGatherLoop);
} else {
cancelAnimationFrame(collectRequestRef.current);
}
};
function dataGatherLoop() {
console.log('inside Loop: ', gatherDataStateRef.current);
let imageFeatures = tf.tidy(function () {
let videoFrameAsTensor = tf.browser.fromPixels(VIDEO);
let resizedTensorFrame = tf.image.resizeBilinear(
videoFrameAsTensor,
[MOBILE_NET_INPUT_HEIGHT, MOBILE_NET_INPUT_WIDTH],
true
);
let normalizedTensorFrame = resizedTensorFrame.div(255);
return mobilenet.predict(normalizedTensorFrame.expandDims()).squeeze();
});
imageFeatures.print();
setTrainData((prev) => ({
trainX: [...prev.trainX, imageFeatures],
trainY: [...prev.trainY, gatherDataStateRef.current],
}));
if (examplesCountRef.current[gatherDataStateRef.current] === undefined) {
examplesCountRef.current[gatherDataStateRef.current] = 1;
} else {
examplesCountRef.current[gatherDataStateRef.current] += 1;
}
collectRequestRef.current = requestAnimationFrame(dataGatherLoop);
}

Delete All Array Elements With Animation

I have a react native todo list app.
The task items are represented by an array using useState which is declared as follows:
const [taskItems, setTaskItems] = useState([]);
I'm trying to add a function that deletes all of the items but instead of just setting it like this setTaskItems([]), which simply deletes all items at once, I was hoping to delete the items one at a time starting from the end to create a more animated look.
while(taskItems.length > 0)
{
alert(taskItems.length);
let itemsCopy = [...taskItems];
itemsCopy.splice(-1);
setTaskItems(itemsCopy);
//setTimeout(function(){},180);
}
For some reason the above code causes infinite recursion. If I remove the while loop then the deleteAll function does indeed remove the last item in the list. Based on my placing the alert at the beginning of the function it seems like the length of the array is never decreased.
Why is that?
And is there a better way to achieve this effect?
It happen because in your code taskItem value doesn't change when you use setTaskItems
setTaskItems is an asynchronous function and the updated value of the state property change after the rerender of the component
you can try something like this
const [taskItems, setTaskItems] = useState([]);
const [deleteAll, setDeleteAll] = useState(false)
useEffect(() => {
if(!deleteAll) return;
if(taskItems.lenght === 0){
setDeleteAll(false)
return;
}
setTimeout(() => {
setTaskItems(t => t.slice(0, -1))
}, 180)
}, [deleteAll, taskItems])
const handleDeleteAll = () => {
seDeleteAll(true)
}
That is because taskItems value is retrieved from closure, meaning you can not expect value to be changed during handler execution. Closure recreates only after element rerenders, meaning you can access new state value only in next function execution - during the function execution value of the state never change.
You just need to assign length value to some temp variable, and use it as start/stop indicator for while loop:
let itemsCount = taskItems.length;
while(itemsCount > 0)
{
// Here use itemsCount to determine how much you would need to slice taskItems array
itemsCount -= 1;
}
UPDATED
An idea of how you should do it is:
const [items, setItems] = useState([1, 2, 3, 4, 5]);
const deleteAll = () => {
let counter = items.length;
while (counter > 0) {
setTimeout(() => {
setItems((p) => [...p.slice(0, counter - 1)]);
}, counter * 1000);
counter -= 1;
}
};
It is important to multiply timeout delay with length, in order to avoid simultaneous execution of all timeouts - you need one by one to be deleted.
Also you should clear timeout on unmount or something like that.
Due to asynchronous nature of React useState hook, you need to harness useEffect to do the job. Here's working example of removing all tasks with a 1000 ms delay between items.
function TasksGrid() {
const [taskItems, setTaskItems] = useState([1, 2, 3, 4, 5, 6]);
const [runDeleteAll, setRunDeleteAll] = useState(false);
useEffect(
() => {
if (runDeleteAll) {
if (taskItems.length > 0)
setTimeout(() => setTaskItems(taskItems.slice(0, taskItems.length - 1)), 1000);
else
setRunDeleteAll(false);
}
},
[runDeleteAll, taskItems]
)
const handleOnClick = () => {
setRunDeleteAll(true);
}
return (
<div>
{taskItems.map((taskItem, idx) => <p key={idx}>task {taskItem}</p>)}
<button onClick={handleOnClick}>delete all</button>
</div>
);
}

Same intersection observer for multiple HTML elements

I am trying to make it so that as some text items stop overlapping a dark background, they will individually change color one by one as the user scrolls. All of the text items are position: fixed
EDIT: The MDN docs say (emphasis mine):
The Intersection Observer API provides a way to asynchronously observe
changes in the intersection of a target element with an ancestor
element
I think this means there is no way to solve my problem because the elements I want to monitor for overlap are not children of the root I am specifying in the options object.
Is there any way to detect overlap if the overlapping element is not a child of the other element?
if ("IntersectionObserver" in window) {
const options = {
root: document.getElementById("flow-landing"),
rootMargin: "0px",
threshold: 0,
};
var callback = function (entries, observer) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.color = "white";
} else {
entry.target.style.color = null;
}
});
};
const observer = new IntersectionObserver(callback, options);
var targets = [
Array.from(document.querySelectorAll(".social-item")),
Array.from(document.querySelectorAll(".additional-item")),
].flat();
targets.forEach((target) => observer.observe(target));
}
There aren't any console errors but the code isn't doing anything.
Modifying Ruslan's answer a little because in his answer, multiple Intersection Observer objects are being created.
It is possible to observe multiple elements using the same observer by calling .observe() on multiple elements.
let observerOptions = {
rootMargin: '0px',
threshold: 0.5
}
var observer = new IntersectionObserver(observerCallback, observerOptions);
function observerCallback(entries, observer) {
entries.forEach(entry => {
if(entry.isIntersecting) {
//do something
}
});
};
let target = '.targetSelector';
document.querySelectorAll(target).forEach((i) => {
if (i) {
observer.observe(i);
}
});
you can do something like that, at least it helps me:
document.querySelectorAll('.social-item').forEach((i) => {
if (i) {
const observer = new IntersectionObserver((entries) => {
observerCallback(entries, observer, i)
},
{threshold: 1});
observer.observe(i);
}
})
const observerCallback = (entries, observer, header) => {
entries.forEach((entry, i) => {
if (entry.isIntersecting) {
entry.target.style.color = "white";
}
else {
entry.target.style.color = null;
}
});
};
You could use the offsetTop and offsetHeight properties instead of the IntersectionObserver API.
For example, when the user scrolls, you can check to see if the offsetTop of element 2 is greater than the offsetTop and less than the offsetHeight of element 1.
WARNING: use debounce because the scroll event's handler function will be called once for every pixel the user scrolls, just imagine the performance nightmare that would occur if user scrolled 600-1000 pixels.
The LoDash documentation, describes it's debounce function as:
"[a function that] creates a debounced function that delays invoking func (handler) until after wait (time) milliseconds have elapsed since the last time the debounced function was invoked."
If you aren't using LoDash, here is a Vanilla JavaScript debounce function:
function debounce(handler, time) {
var timeout;
return function() {
var self = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() {
return handler.apply(self, args);
}, time);
};
}
Here is the code that'll allow you to "do stuff" if element 2 "intersects" element 1.
let element_1 = document.querySelector("#my-element-1");
let element_2 = document.querySelector("#my-element-2");
window.addEventListener("scroll", debounce(() => {
if(element_2.offsetTop > element_1.offsetTop && element_2.offsetTop < element_1.offsetHeight) {
console.log("The elements are intersecting.");
}
}, 100));
In case that looks complex or hard to read, here is the same code, broken up into smaller chunks:
let element_1 = document.querySelector("#my-element-1");
let element_2 = document.querySelector("#my-element-2");
window.addEventListener("scroll", debounce(() => {
let x = element_2.offsetTop > element_1.offsetTop;
let y = element_2.offsetTop < element_1.offsetHeight;
if(x && y) {
console.log("The elements are intersecting.");
}
}, 250));
Notes & information:
you could use the >= and <= operators instead of the > and < operators
a wait time that's too long could make the effect look unnatural and forced.
Good luck.

Categories

Resources