I am trying to merge these two hooks to get the direction and the percentage of the scroll.
useScrollPercentage.js
import { useRef, useState, useEffect } from "react";
export default function useScrollPercentage() {
const scrollRef = useRef(null);
const [scrollPercentage, setScrollPercentage] = useState(NaN);
const reportScroll = e => {
setScrollPercentage(getScrollPercentage(e.target));
};
useEffect(
() => {
const node = scrollRef.current;
if (node !== null) {
node.addEventListener("scroll", reportScroll, { passive: true });
if (Number.isNaN(scrollPercentage)) {
setScrollPercentage(getScrollPercentage(node));
}
}
return () => {
if (node !== null) {
node.removeEventListener("scroll", reportScroll);
}
};
},
[scrollPercentage]
);
return [scrollRef, Number.isNaN(scrollPercentage) ? 0 : scrollPercentage];
}
function getScrollPercentage(element) {
if (element === null) {
return NaN;
}
const height = element.scrollHeight - element.clientHeight;
return Math.round((element.scrollTop / height) * 100);
}
useScrollDirection.js
import {useState, useEffect} from 'react';
import _ from 'lodash';
export default function 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;
}
Currently I am using it like this
App.js
const [scrollRef, scrollPercentage] = useScrollPercentage();
const scrollDirection = useScrollDirection({ref: scrollRef});
const handleScroll = () => {
console.log(scrollPercentage, scrollDirection);
}
return (
<Modal ref={scrollRef} onScroll={handleScroll}>
// Scrollable Content
</Modal>
)
The problem is that since I don't want to trigger useScrollDirection for every scroll percentage unless the scroll direction changed only it should trigger. Also is it possible to merge these hooks?
Any help is appreciated
Related
I want to test carousel in React-testing-library.
I can't check whether state is changed in RTL like enzyme does, so...
What I've done is just give carousel a scrollX and check whether it is changed
fireEvent.scroll(element, { target: { scrollX: 100 } });
// check
element.scrollLeft...
Is it collect or any ideas?
import React, { useRef } from "react";
import "./Carousel.css";
export interface CarouselProps {
children: React.ReactNode;
}
const Carousel = ({ children }: CarouselProps) => {
const carouselRef = useRef<HTMLDivElement>(null);
let isDown = false;
let startX = 0;
let scrollLeft = 0;
const onMouseDownCarousel: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!carouselRef || !carouselRef.current) {
return;
}
isDown = true;
carouselRef.current.classList.add("active");
startX = e.pageX - carouselRef.current.offsetLeft;
scrollLeft = carouselRef.current.scrollLeft;
};
const onMouseLeaveCarousel: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!carouselRef || !carouselRef.current) {
return;
}
isDown = false;
carouselRef.current.classList.remove("active");
};
const onMouseUpCarousel: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!carouselRef || !carouselRef.current) {
return;
}
isDown = false;
carouselRef.current.classList.remove("active");
};
const onMouseMoveCarousel: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!carouselRef || !carouselRef.current) {
return;
}
if (!isDown) return;
e.preventDefault();
const x = e.pageX - carouselRef.current.offsetLeft;
const walk = (x - startX) * 3;
carouselRef.current.scrollLeft = scrollLeft - walk;
};
return (
<div
className="carousel"
onMouseDown={onMouseDownCarousel}
onMouseLeave={onMouseLeaveCarousel}
onMouseUp={onMouseUpCarousel}
onMouseMove={onMouseMoveCarousel}
ref={carouselRef}
>
{children}
</div>
);
};
export default Carousel;
I have the following usage of my hook, but it doesn't use the new timerDuration when I update inside my input:
const [secondsBetweenRepsSetting, setSecondsBetweenRepsSetting] = useState(DEFAULT_SECONDS_BETWEEN_REPS)
const {secondsLeft, isRunning, start, stop} = useTimer({
duration: secondsBetweenRepsSetting,
onExpire: () => sayRandomExerciseName(),
onTick: () => handleTick(),
});
const onTimeBetweenRepsChange = (event: any) => {
const secondsBetweenRepsSettingString = event.target.value;
const secondsBetweenRepsSettingInt = parseInt(secondsBetweenRepsSettingString)
setSecondsBetweenRepsSetting(secondsBetweenRepsSettingInt)
}
return <React.Fragment>
<input type="number" name="secondsBetweenRepsSetting" value={secondsBetweenRepsSetting} onChange={onTimeBetweenRepsChange}/>
</React.Fragment>
And here is the implementation of the useTimer hook, which I'm not sure why it's not getting my duration update?
import { useState } from 'react';
import Validate from "../utils/Validate";
import useInterval from "./useInterval";
export default function useTimer({ duration: timerDuration, onExpire, onTick}) {
const [duration] = useState(timerDuration)
const [secondsLeft, setSecondsLeft] = useState(timerDuration)
const [isRunning, setIsRunning] = useState(false)
function start() {
setIsRunning(true)
}
function stop() {
setIsRunning(false)
}
function handleExpire() {
Validate.onExpire(onExpire) && onExpire();
}
useInterval(() => {
const secondsMinusOne = secondsLeft - 1;
setSecondsLeft(secondsMinusOne)
if(secondsMinusOne <= 0) {
setSecondsLeft(duration) // Reset timer automatically
handleExpire()
} else {
Validate.onTick(onTick) && onTick();
}
}, isRunning ? 1000 : null)
return {secondsLeft, isRunning, start, stop, }
}
Remove the line:
const [duration] = useState(timerDuration);
You are already getting duration from timerDuration, just use that.
Recently I picked up a project that has d3-flame-graph on it and the graph is displayed according to the Filters defined on another component.
My issue is that when searching with new parameters I can't seem to clean the previous chart and I was wondering if someone could help me. Basically what I'm having right now is, when I first enter the page, the loading component, then I have my graph and when I search for a new date I have the loading component but on top of that I still have the previous graph
I figured I could use flamegraph().destroy() on const updateGraph but nothing is happening
import React, { FC, useEffect, useRef, useState, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import moment from 'moment'
import * as d3 from 'd3'
import { flamegraph } from 'd3-flame-graph'
import Filters, { Filter } from '../../../../../../components/Filters'
import { getFlamegraph } from '../../../../../../services/flamegraph'
import { useQueryFilter } from '../../../../../../hooks/filters'
import FlamegraphPlaceholder from '../../../../../../components/Placeholders/Flamegraph'
import css from './flamegraph.module.css'
import ToastContainer, {
useToastContainerMessage,
} from '../../../../../../components/ToastContainer'
const defaultFilters = {
startDate: moment().subtract(1, 'month'),
endDate: moment(),
text: '',
limit: 10,
}
const getOffSet = (divElement: HTMLDivElement | null) => {
if (divElement !== null) {
const padding = 100
const minGraphHeight = 450
// ensure that the graph has a min height
return Math.max(
window.innerHeight - divElement.offsetTop - padding,
minGraphHeight
)
} else {
const fallBackNavigationHeight = 300
return window.innerHeight - fallBackNavigationHeight
}
}
const Flamegraph: FC = () => {
const [queryFilters, setQueryFilters] = useQueryFilter(defaultFilters)
const [fetching, setFetching] = useState(false)
const [graphData, setGraphData] = useState()
const {
messages: toastMessages,
addMessage: addMessageToContainer,
removeMessage: removeMessageFromContainer,
} = useToastContainerMessage()
const flameContainerRef = useRef<HTMLDivElement | null>(null)
const flameRef = useRef<HTMLDivElement | null>(null)
const graphRef = useRef<any>()
const graphDataRef = useRef<any>()
const timerRef = useRef<any>()
const { projectId, functionId } = useParams()
let [sourceId, sourceLine] = ['', '']
if (functionId) {
;[sourceId, sourceLine] = functionId.split(':')
}
const createGraph = () => {
if (flameContainerRef.current && flameRef.current) {
graphRef.current = flamegraph()
.width(flameContainerRef.current.offsetWidth)
.height(getOffSet(flameRef.current))
.cellHeight(30)
.tooltip(false)
.setColorMapper(function(d, originalColor) {
// Scale green component proportionally to box width (=> the wider the redder)
let greenHex = (192 - Math.round((d.x1 - d.x0) * 128)).toString(16)
return '#FF' + ('0' + greenHex).slice(-2) + '00'
})
}
}
const updateGraph = (newData: any) => {
setGraphData(newData)
graphDataRef.current = newData
if (graphRef.current) {
if (newData === null) {
graphRef.current.destroy()
graphRef.current = null
} else {
d3.select(flameRef.current)
.datum(newData)
.call(graphRef.current)
}
}
}
const fetchGraph = (filters: Filter) => {
setFetching(true)
getFlamegraph(
Number(projectId),
filters.startDate ? filters.startDate.unix() : 0,
filters.endDate ? filters.endDate.unix() : 0,
sourceId,
sourceLine
)
.then(graphData => {
if (!graphRef.current) {
createGraph()
}
updateGraph(graphData)
})
.catch(({ response }) => {
updateGraph(null)
if (response.data) {
addMessageToContainer(response.data.message, true)
}
})
.finally(() => {
setFetching(false)
})
}
const onResize = useCallback(() => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (graphRef.current && flameContainerRef.current) {
graphRef.current.width(flameContainerRef.current.offsetWidth)
d3.select(flameRef.current)
.datum(graphDataRef.current)
.call(graphRef.current)
}
}, 500)
}, [])
useEffect(() => {
fetchGraph(queryFilters)
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onChangeFilters = (filters: Filter) => {
setQueryFilters(filters)
fetchGraph(filters)
}
return (
<div className={css.host}>
<Filters
defaultValues={queryFilters}
searching={fetching}
onSearch={onChangeFilters}
/>
<div className={css.flameBox}>
<div className={css.flameContainer} ref={flameContainerRef}>
<div ref={flameRef} />
</div>
{fetching || !graphData ? (
<FlamegraphPlaceholder loading={fetching} />
) : null}
</div>
<ToastContainer
messages={toastMessages}
toastDismissed={removeMessageFromContainer}
/>
</div>
)
}
export default Flamegraph
Firstly, flamegraph() creates a new instance of flamegraph, you'd need to use graphref.current.destroy(). Secondly, you'd want to destroy this not when the data has already been loaded, but just as it starts to load, right? Because that's the operation that takes time.
Consider the following:
const cleanGraph = () => {
if (graphref.current !== undefined) {
graphref.current.destroy()
}
}
const fetchGraph = (filters: Filter) => {
setFetching(true)
cleanGraph()
getFlamegraph(
Number(projectId),
filters.startDate ? filters.startDate.unix() : 0,
filters.endDate ? filters.endDate.unix() : 0,
sourceId,
sourceLine
)
...
}
I'm building Carousel Component using useLayoutEffect. this useLayoutEffect hook has been set in resizeWindow.ts separately. and resizeWindow function is called in functional Component named carousel. I can't find where breaking rule is.
//resizeWindow.ts
import { useLayoutEffect, useState, RefObject } from 'react'
/***
* #function resizeWindow
* this function is custom hook for grab resizing innerWidth of element.
*
*
*/
export const resizeWindow: (ref: RefObject<HTMLElement>) => number[] = (ref) => {
const [ elementWidth, elementHeight ] = ref.current ?
[ref.current.offsetWidth, ref.current.offsetHeight ] :
[0,0];
const [size, setSize] = useState([elementWidth, elementHeight]);
useLayoutEffect(() => {
const updateSize = () => {
setSize([elementWidth, elementHeight]);
console.log(`elementWidth: ${elementWidth}px`);
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
},[]);
return size;
};
//carousel.ts
//
import { resizeWindow } from './resizeWindow.ts';
export const Carousel: FC = ({
children
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const slideRef = createRef<HTMLDivElement>();
const [count, setCount ] = useState<number>(0);
const [parentWidth, setParentWidth] = resizeWindow(parentRef);
const total = React.Children.count(children);
const nextSlide = () => {
if( count < total -1 ){
setCount( count + 1 );
} else if( count === total-1 ){
setCount(1);
}
}
const prevSlide = () => {
if( count > 0 ){
setCount( count -1 );
} else if( count === 0 ){
setCount(total -1 );
}
}
useEffect(()=> {
console.log('parentRef: ', parentRef);
if(slideRef.current){
slideRef.current.style.transition = "all 0.5s ease-in-out";
slideRef.current.style.transform = `translateX(-${count}00%)`;
}
if(parentRef.current){
resizeWindow(parentRef);
}
},[count || parentWidth])
return(
<SliderContainer ref={parentRef}>
<Slider ref={slideRef} width={parentWidth * total}>
{children}
</Slider>
<Indicator now={1} total={total}/>
<Button onClick={prevSlide}>left</Button>
<Button onClick={nextSlide}>right</Button>
</SliderContainer>
)
}
resizeWindow is a custom hook, you should not be using it inside useEffect hook. This usage is what gives you an error.
Also you must name your custom hooks by prefixing their name with use
Also you must destructure ref properties within the updateSize function in resizeWindow hook so that you don't face the closure problem within updateSize function
The updated solution will look like
export const useResizeWindow: (ref: RefObject<HTMLElement>) => number[] = (ref) => {
const [size, setSize] = useState([elementWidth, elementHeight]);
useLayoutEffect(() => {
const updateSize = () => {
const [ elementWidth, elementHeight ] = ref.current ?
[ref.current.offsetWidth, ref.current.offsetHeight ] :
[0,0];
setSize([elementWidth, elementHeight]);
console.log(`elementWidth: ${elementWidth}px`);
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
},[]);
return size;
};
and its usage will be as follows
//carousel.ts
//
import { useResizeWindow } from './resizeWindow.ts';
export const Carousel: FC = ({
children
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const slideRef = createRef<HTMLDivElement>();
const [count, setCount ] = useState<number>(0);
const [parentWidth, setParentWidth] = useResizeWindow(parentRef);
const total = React.Children.count(children);
const nextSlide = () => {
if( count < total -1 ){
setCount( count + 1 );
} else if( count === total-1 ){
setCount(1);
}
}
const prevSlide = () => {
if( count > 0 ){
setCount( count -1 );
} else if( count === 0 ){
setCount(total -1 );
}
}
useEffect(()=> {
console.log('parentRef: ', parentRef);
if(slideRef.current){
slideRef.current.style.transition = "all 0.5s ease-in-out";
slideRef.current.style.transform = `translateX(-${count}00%)`;
}
},[count || parentWidth])
return(
<SliderContainer ref={parentRef}>
<Slider ref={slideRef} width={parentWidth * total}>
{children}
</Slider>
<Indicator now={1} total={total}/>
<Button onClick={prevSlide}>left</Button>
<Button onClick={nextSlide}>right</Button>
</SliderContainer>
)
}
i've got Tabs component, it has children Tab components. Upon mount it calculates meta data of Tabs and selected Tab. And then sets styles for tab indicator. For some reason function updateIndicatorState triggers several times in useEffect hook every time active tab changes, and it should trigger only once. Can somebody explain me what I'm doing wrong here? If I remove from deps of 2nd useEffect hook function itself and add a value prop as dep. It triggers correctly only once. But as far as I've read docs of react - I should not cheat useEffect dependency array and there are much better solutions to avoid that.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { defProperty } from 'helpers';
const Tabs = ({ children, value, orientation, onChange }) => {
console.log(value);
const indicatorRef = useRef(null);
const tabsRef = useRef(null);
const childrenWrapperRef = useRef(null);
const valueToIndex = new Map();
const vertical = orientation === 'vertical';
const start = vertical ? 'top' : 'left';
const size = vertical ? 'height' : 'width';
const [mounted, setMounted] = useState(false);
const [indicatorStyle, setIndicatorStyle] = useState({});
const [transition, setTransition] = useState('none');
const getTabsMeta = useCallback(() => {
console.log('getTabsMeta');
const tabsNode = tabsRef.current;
let tabsMeta;
if (tabsNode) {
const rect = tabsNode.getBoundingClientRect();
tabsMeta = {
clientWidth: tabsNode.clientWidth,
scrollLeft: tabsNode.scrollLeft,
scrollTop: tabsNode.scrollTop,
scrollWidth: tabsNode.scrollWidth,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
}
let tabMeta;
if (tabsNode && value !== false) {
const wrapperChildren = childrenWrapperRef.current.children;
if (wrapperChildren.length > 0) {
const tab = wrapperChildren[valueToIndex.get(value)];
tabMeta = tab ? tab.getBoundingClientRect() : null;
}
}
return {
tabsMeta,
tabMeta,
};
}, [value, valueToIndex]);
const updateIndicatorState = useCallback(() => {
console.log('updateIndicatorState');
let _newIndicatorStyle;
const { tabsMeta, tabMeta } = getTabsMeta();
let startValue;
if (tabMeta && tabsMeta) {
if (vertical) {
startValue = tabMeta.top - tabsMeta.top + tabsMeta.scrollTop;
} else {
startValue = tabMeta.left - tabsMeta.left;
}
}
const newIndicatorStyle =
((_newIndicatorStyle = {}),
defProperty(_newIndicatorStyle, start, startValue),
defProperty(_newIndicatorStyle, size, tabMeta ? tabMeta[size] : 0),
_newIndicatorStyle);
if (isNaN(indicatorStyle[start]) || isNaN(indicatorStyle[size])) {
setIndicatorStyle(newIndicatorStyle);
} else {
const dStart = Math.abs(indicatorStyle[start] - newIndicatorStyle[start]);
const dSize = Math.abs(indicatorStyle[size] - newIndicatorStyle[size]);
if (dStart >= 1 || dSize >= 1) {
setIndicatorStyle(newIndicatorStyle);
if (transition === 'none') {
setTransition(`${[start]} 0.3s ease-in-out`);
}
}
}
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
}, 350);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
let childIndex = 0;
const childrenItems = React.Children.map(children, child => {
const childValue = child.props.value === undefined ? childIndex : child.props.value;
valueToIndex.set(childValue, childIndex);
const selected = childValue === value;
childIndex += 1;
return React.cloneElement(child, {
selected,
indicator: selected && !mounted,
value: childValue,
onChange,
});
});
const styles = {
[size]: `${indicatorStyle[size]}px`,
[start]: `${indicatorStyle[start]}px`,
transition,
};
console.log(styles);
return (
<>
{value !== 2 ? (
<div className={`tabs tabs--${orientation}`} ref={tabsRef}>
<span className="tab__indicator-wrapper">
<span className="tab__indicator" ref={indicatorRef} style={styles} />
</span>
<div className="tabs__wrapper" ref={childrenWrapperRef}>
{childrenItems}
</div>
</div>
) : null}
</>
);
};
Tabs.defaultProps = {
orientation: 'horizontal',
};
Tabs.propTypes = {
children: PropTypes.node.isRequired,
value: PropTypes.number.isRequired,
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
onChange: PropTypes.func.isRequired,
};
export default Tabs;
useEffect(() => {
if (mounted) {
console.log('1st call mounted');
updateIndicatorState();
}
}, [mounted, updateIndicatorState]);
This effect will trigger whenever the value of mounted or updateIndicatorState changes.
const updateIndicatorState = useCallback(() => {
...
}, [getTabsMeta, indicatorStyle, size, start, transition, vertical]);
The value of updateIndicatorState will change if any of the values in its dep array change, namely getTabsMeta.
const getTabsMeta = useCallback(() => {
...
}, [value, valueToIndex]);
The value of getTabsMeta will change whenever value or valueToIndex changes. From what I'm gathering from your code, value is the value of the selected tab, and valueToIndex is a Map that is re-defined on every single render of this component. So I would expect the value of getTabsMeta to be redefined on every render as well, which will result in the useEffect containing updateIndicatorState to run on every render.