I'm trying to write basic hooks to get currentScroll, lastScroll, scrollSpeed while scrolling.
function useDocScroll() {
const isClient = typeof window === "object"
function getScroll() {
return isClient
? window.pageYOffset || document.documentElement.scrollTop
: undefined
}
const [docScroll, setDocScroll] = useState(getScroll)
const [lastScroll, setLastScroll] = useState(null)
const [scrollSpeed, setScrollSpeed] = useState(Math.abs(docScroll - lastScroll))
useEffect(() => {
if (!isClient) {
return false
}
function handleScroll() {
setDocScroll(getScroll())
setLastScroll(getScroll()) // <-- why is this working?
// setLastScroll(docScroll) // <-- why is this not working?
setScrollSpeed(Math.abs(docScroll - lastScroll)) // <-- why is this not working?
}
window.addEventListener("scroll", handleScroll)
}, [])
return [docScroll, lastScroll, scrollSpeed]
}
It seems like when I do setLastScroll(getScroll()), it saves the last scroll value well.
But I don't understand because when handleScroll() is firing, shouldn't getScroll() value stays the same? I don't get it why setDocScroll(getScroll()) and setLastScroll(getScroll()) have different value.
Also, I thought I could do setLastScroll(docScroll), meaning 'set lastScroll value with current docScroll value', but it just prints '0' while docScroll value changes.
Why is this? I want to understand better.
+) And I can't get scrollSpeed which is calculated by docScroll and lastScroll, but I don't know how to get those values.
I think why your code not working is because of following two reasons:
using docScroll directly after setDocScroll won't work because setState is asynchronous task. There is no guarantee that docScroll is updated before executing next statement
you are getting 0 because of scrolling happening inside some particular element (probably). Since document.documentElement points to html element and there is no scrolling inside it. So you receive 0
Solution:
You don't need multiple useStates. Since scroll events emits too frequently, i think its good idea to use useReducer to reduce number of renders. It is important understand where scrolling happening whether on root level or inside some element.
For below solution i proposed:
if scroll happening on root level (html element) no need to pass element to useDocScroll. If scroll happening inside particular element, you need to pass element reference.
const initState = {
current: 0,
last: 0,
speed: 0,
};
function reducer(state, action) {
switch (action.type) {
case "update":
return {
last: state.current,
current: action.payload,
speed: Math.abs(action.payload - state.current) || 0,
};
default:
return state;
}
}
const isClient = () => typeof window === "object";
function useDocScroll(element = document.documentElement) {
const [{ current, last, speed }, dispatch] = useReducer(reducer, initState);
function getScroll() {
return isClient() ? element.scrollTop : 0;
}
function handleScroll() {
dispatch({ type: "update", payload: getScroll() });
}
useEffect(() => {
if (!isClient()) {
return false;
}
element.addEventListener("scroll", handleScroll);
return () => element.removeEventListener("scroll", handleScroll);
}, []);
return [current, last, speed];
}
Example:
if scroll happening inside window
const {current, last, speed} = useDocScroll()
if scroll happening in particular element
const {current, last, speed} = useDocScroll(document.getElementById("main"))
It not working due to closures:
useEffect(() => {
if (!isClient) {
return false;
}
function handleScroll() {
setDocScroll(getScroll());
// On calling handleScroll the values docScroll & lastScroll
// are always will be of the first mount,
// the listener "remembers" (closures) such values and never gets updated
setLastScroll(docScroll);
setScrollSpeed(Math.abs(docScroll - lastScroll));
// v Gets updated on every call
setLastScroll(getScroll());
}
// v Here you assigning a callback which closes upon the lexical scope of
// docScroll and lastScroll
window.addEventListener('scroll', handleScroll);
}, []);
To fix it, a possible solution can be a combination of a reference (useRef) and functional setState.
For example:
setScrollSpeed(lastScroll => Math.abs(getScroll() - lastScroll))
Not sure why lastScrolled needed:
function useDocScroll() {
const [docScroll, setDocScroll] = useState(getScroll());
const lastScrollRef = useRef(null);
const [scrollSpeed, setScrollSpeed] = useState();
useEffect(() => {
if (!isClient) {
return false;
}
function handleScroll() {
const lastScroll = lastScrollRef.current;
const curr = getScroll();
setScrollSpeed(Math.abs(getScroll() - (lastScroll || 0)));
setDocScroll(curr);
// Update last
lastScrollRef.current = curr;
}
window.addEventListener('scroll', handleScroll);
}, []);
return [docScroll, lastScrollRef.current, scrollSpeed];
}
Related
I'm trying to set up a custom context menu, however whenever the user right clicks the context menu function returns 6 separate times, the 5th being what I need and the 6th being the default state values. However if the user double right-clicks in the same spot it returns 5 times, with the 5th return being the desired values and the menu opens. Is there a way to check before the return if all the states are changed and only return from the callback if all the needed information is present?
const ContextMenu = outerRef => {
const [xPos, setXPos] = useState("0px");
const [yPos, setYPos] = useState("0px");
const [menu, showMenu] = useState(false);
const [menuTarget, setMenuTarget] = useState('');
const [menuTargetId, setMenuTargetId] = useState('');
const handleContextMenu = useCallback(
event => {
if(event.target.className && (event.target.className.includes('bar') ||event.target.className == 'timeline' || event.target.className == 'draggablediv' || event.target.className == 'editableDiv')){
event.preventDefault();
if (outerRef && outerRef.current.contains(event.target)) {
setXPos(`${event.pageX}px`);
setYPos(`${event.pageY}px`);
setMenuTarget(event.target.className)
setMenuTargetId(event.target.id)
showMenu(true);
} else {
showMenu(false);
}
}
},[showMenu, outerRef, setXPos, setYPos]);
const handleClick = useCallback(() => {
showMenu(false);
}, [showMenu]);
useEffect(() => {
document.addEventListener("click", handleClick);
document.addEventListener("contextmenu", handleContextMenu);
return () => {
document.removeEventListener("click", handleClick);
document.removeEventListener("contextmenu", handleContextMenu);
};
}, []);
return {xPos, yPos, menu, menuTarget, menuTargetId};
};
useCallback accepts a function that returns the memoized value. Like useCallback(() => 5, []), or in your case useCallback(() => event => {...}, []).
This is because React has to call the function to get the value to memoize, so your setters are being called on every render. Which is what's causing all the weirdness.
However, even with that fix I don't think it's correct to use a function that changes references in addEventListener. You will never have up-to-date values in your contextmenu handler because it will refer to an old version of handleContextMenu. You will probably have to use a more idiomatic way of attaching a function to a UI event than the global document api.
I have a working animation of an object made with the "useRef" hook. Part of the code in this animation will be repeated several times, so I moved it into a separate function, but when I try to call this function, when rendering the component, I get the error "Can't assign to property" scrollLeft "on 1: not an object" what could be the problem?
Full code on codesandbox
https://codesandbox.io/s/peaceful-silence-bm6hx?file=/src/scroll.js
import React, {useState, useEffect, useRef} from 'react'
const Scrollable = props => {
const items = props.items;
let ref = useRef()
const [state, setState] = useState({
isScrolling:false,
clientX:0,
scrollX:0
})
const [touchStart, setTouchStart] = useState(0);
let frameId;
const onMouseDown = e =>{...}
const onMouseUp = e =>{
if(ref && ref.current && !ref.current.contains(e.target)) {
return;
}
e.preventDefault()
let touchShift = touchStart - state.clientX
let rez;
let shift;
if(touchShift > 0) {
shift = 300 - touchShift
rez = state.scrollX + shift
if(rez>2100){
rez =1800
cancelAnimationFrame(frameId)
}
let speed = shift / 20
let cur = state.scrollX
frameId = requestAnimationFrame(animate)
animate(cur,speed,rez)
}
}
const animate = (cur, speed,rez) => {
frameId = requestAnimationFrame(animate)
cur = cur + speed
ref.current.scrollLeft = cur.toFixed(2)
if (Math.round(cur) === rez) {
cancelAnimationFrame(frameId)
setState({
...state,
scrollX:rez,
isScrolling:false,
})
}
}
useEffect(() =>{
document.addEventListener('mousedown',onMouseDown)
document.addEventListener('mouseup',onMouseUp)
return () => {
document.removeEventListener('mousedown',onMouseDown)
document.removeEventListener('mouseup',onMouseUp)
}
})
useEffect(() =>{
ref.current = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(ref.current)
},[]})
return (
<div className={classes.charPage}>
<div
ref={ref}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}>
</div>
</div>
)
}
export default Scrollable;
This error means you're trying to set a property on a number. In your useEffect you're doing this:
ref.current = requestAnimationFrame(animate)
requestAnimationFrame returns, according to MDN:
A long integer value, the request id, that uniquely identifies the entry in the callback list. This is a non-zero value, but you may not make any other assumptions about its value.
But you're also using the same ref for your DOM element. After your useEffect runs it will have set your ref to the rAF id which is a number causing your error when you try to set the scrollLeft property on the ref.
What you can try next to solve this is to use 2 separate refs, one for the requestAnimationFrame and one for your DOM element.
The new React Hooks feature is cool but it sometimes makes me confused. In particular, I have this code wrapped in useEffect hook:
const compA = ({ num }) => {
const [isPositive, check] = useState(false);
useEffect(() => {
if (num > 0) check(true);
}, []);
return (//...JSX);
};
The code inside the above useEffect will be executed only once. So what are the differences if I bring the code out of the useEffect, like below:
const compA = ({ num }) => {
const [isPositive, check] = useState(false);
if (num > 0) check(true);
return (//...JSX);
};
in the second case the code will be executed at every re-render.
this is a better version of the component:
const compA = ({ num }) => {
const [isPositive, check] = useState(false);
useEffect(() => {
if (num > 0) check(true);
}, [num]);
return (//...JSX);
};
In this case the effect (which depends heavily on num) is used only when the num prop has changed.
for reference:
https://reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect
Anyway, in my opinion using a side effect in this very simple case is overkill!
The code will run faster by checking if num > 0 at every render than checking first if num changed and then if it's > 0..
So you should probably just avoid useEffect and stick to your second piece of code
I have an object State().score that should be updated with it's added value when it is being called in Handler().addToScore(). The thing is that it always stays at it's initial value which is 0.
const DOM = () => {
const dom = {}
dom.score = document.getElementsByClassName('score')[0]
return dom
}
const State = () => {
const state = {}
state.score = 0 // This remains 0, I want it to update when adding to it
return state
}
const Handler = () => {
const handler = {}
handler.addToScore = function() {
State().score += 10
console.log(State().score) // Equals to 0 on every click and never gets updated
DOM().score.innerHTML = State().score
}
return handler
}
function checkLoginState() {
FB.getLoginStatus(function(response) {
statusChangeCallback(response);
});
}
function statusChangeCallback(response) {
if(response.status === 'connected') {
console.log( 'Logged in and authenticated' )
Handler().addToScore()
} else {
console.log('Not authenticated')
}
}
Every-time you run State() it sets returns a fresh object with score being 0
You will have to save the outcome of the intialization of your State().score if you want it to be saved. Or, you can change up the way that you're generating score by using a getter and a setter
const State = () => {
const state = {}
state.score = 0 // This remains 0, I want it to update when adding to it
return state
}
console.log(State().score); // State() is a function that ALWAYS returns { score: 0 }
let savedScore = State().score;
savedScore += 1;
console.log(savedScore);
Example using get/set (there's multiple ways to do this:
https://jsfiddle.net/mswilson4040/1ds8mbqw/3/
class State {
constructor() {
this._score = 0;
}
get score() {
return this._score;
}
set score(val) {
this._score = val;
}
}
const state = new State();
console.log(state.score);
state.score += 1;
console.log(state.score);
One other way to do this, of course, it to not make State a function. It looks like you're actually trying to manage a score or state so having State be a function that ultimately gives you a brand new state (score) everytime isn't going to work.
Something as simple as just not having State be a function would also work:
const State = () => {
const state = {}
state.score = 0 // This remains 0, I want it to update when adding to it
return state
}
Should be
const State = {
score: 0
};
InfiniteScrollFactory:
const scrollingSocial = (e) => {
console.log('scrollingSocial');
// e.stopPropagation();
const reachedBottom = () => socialCol.scrollHeight - socialCol.scrollTop === socialCol.offsetHeight;
const loadMoreItems = () => {
console.log('[ Fire Once ] loadMoreItems...');
$rootScope.$emit("socialmedia.stream.load");
};
if (reachedBottom()) loadMoreItems();
};
const wireSocialScroll = (list) => {
console.log('wireSocialScroll called!');
if (notEmpty(list)) {
socialCol.addEventListener('scroll', scrollingSocial);
}
};
const attachScrollListener = (location, col, list) => {
console.log('attachScrollListener');
console.log(' location', location);
switch (location) {
// case 'tagsPanel' : tagsCol = col; wireTagsScroll(list); break;
// case 'feedPanel' : feedCol = col; wireFeedScroll(list); break;
case 'socialMedia' : socialCol = col; wireSocialScroll(list); break;
}
};
My scrollingSocial function gets called once when I scroll down the mouse once. It takes about 45 'scrolls' to finally trigger my loadMoreItems function. However then it gets called twice. And I see the scroll the 46th time even though I did not scroll a 46th time.
socialMediaDirective:
const getColHeight = (tags) => {
if (notEmpty(tags)) InfiniteScrollFactory.attachScrollListener('socialMedia', socialCol, tags);
};
Scrolling and it's event triggers can be a bit finicky.
Just using this code:
$(document).on('scroll', () => console.log('scroll'));
I get multiple scrolls each time I tick my mouse wheel, no matter how carefully I do so.
It's probably the same sort of issue with what you have. What you'll want to do is simply add a boolean that keeps track of if you've called loadMoreItems, use that boolean to keep it from calling it again.
let loadingMoreItems = false;
const scrollingSocial = (e) => {
console.log('scrollingSocial');
// e.stopPropagation();
const reachedBottom = () => socialCol.scrollHeight - socialCol.scrollTop === socialCol.offsetHeight;
const loadMoreItems = () => {
console.log('[ Fire Once ] loadMoreItems...');
$rootScope.$emit("socialmedia.stream.load");
};
if (!loadingMoreItems && reachedBottom()) {
loadingMoreItems = true;
loadMoreItems();
}
};
Then, at an appropriate time (or times), change that boolean back to false to allow it to call again (scrolling back up, more items loaded, reachedBottom() resulting in false once, etc).