I have a functional component that holds custom viewport values in its state, so it must use event listeners and measure the window size:
const AppWrap = () => {
// custom vw and vh vars
const [vw, setvw] = useState();
const [vh, setvh] = useState();
// gets the inner height/width to act as viewport dimensions (cross-platform benefits)
const setViewportVars = () => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// can be accessed in scss as vw(n), vh(n) OR in css as --vw * n, --vh * n
document.documentElement.style.setProperty('--vw', `${viewportWidth / 100}px`);
document.documentElement.style.setProperty('--vh', `${viewportHeight / 100}px`);
// can be accessed in child components as vw * n or vh * n
setvw(viewportWidth / 100);
setvh(viewportHeight / 100);
}
// I'd like to run this function *once* when the component is initialized
setViewportVars();
// add listeners
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
return (
<App vw={vw} vh={vh}/>
);
}
The above code produces an error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
I can wrap setViewportVars() in useEffect, but I don't see why this is necessary. My understanding of functional components is that they only run code outside of the return statement once, and that only the JSX would re-render on a state change.
You have to use useEffect and pass empty array as dependencies, so this will only be excecuted once just like componentDidMount:
useEffect(() => {
setViewportVars();
// add listeners
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
}, []);
So in your case what happens is basically you call the function it will update the state, so again component will load again function will call so basically that goes to infinite loop
Solution
you can useEffect, so in useEffect if you pass the second argument which is an array as empty it will called only one time like the componentDidMount
useEffect(() => {
setViewportVars()
}, [])
So if you pass second argument
Passing nothing, like useEffect(() => {}) - it will call every time.
Passing an empty array useEffect(() => {}, []) - it will call one time.
Passing array deps, whenever the array dependencies changes it will execute the code block inside the usEffect.
useEffect(() => {
// some logic
}, [user])
The answer to why you need to useEffect() to prevent the infinite re-render:
<AppWrap> has state {vw} and {vh}. When <AppWrap>is fired, setViewportVars() immediately runs and updates that state. Because you updated the state, setViewportVars() is then fired again (to keep in line with the react one way data flow which updates the state of {vw/vh} and causes a re-firing of AppWrap ...which causes a re-firing of setViewportVars(). At no point here have we allowed the DOM to get painted by the browser, we are just repeating the loop of:
init component > getHeight/Width > updateState > re-render component > getHeight/Width > ...
useEffect behaves differently than a regular render. useEffect fires only after a the DOM has been painted by the browser. Which means that the first cycle would finish (init component > browser paints DOM > useEffect(getHeight/Width) > |if state aka viewsize changed?| > re-render)
For more info, check out Dan Abramov's blog on useEffect
const AppWrap = () => {
// custom vw and vh vars
const [vw, setvw] = useState();
const [vh, setvh] = useState();
// gets the inner height/width to act as viewport dimensions (cross-platform benefits)
const setViewportVars = useCallback(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// can be accessed in scss as vw(n), vh(n) OR in css as --vw * n, --vh * n
document.documentElement.style.setProperty('--vw', `${viewportWidth / 100}px`);
document.documentElement.style.setProperty('--vh', `${viewportHeight / 100}px`);
// can be accessed in child components as vw * n or vh * n
setvw(viewportWidth / 100);
setvh(viewportHeight / 100);
}, []);
useEffect(() => {
window.addEventListener('resize', setViewportVars);
window.addEventListener('orientationchange', setViewportVars);
window.addEventListener('fullscreenchange', setViewportVars);
return () => {
window.removeEventListener('resize', setViewportVars);
window.removeEventListener('orientationchange', setViewportVars);
window.removeEventListener('fullscreenchange', setViewportVars);
}
}, []);
useEffect(() => {
// I'd like to run this function *once* when the component is initialized
setViewportVars();
}, []);
return (
<App vw={vw} vh={vh} />
);
}
Related
I have an array that I am mapping over and rendering a component for each item. I want to reduce the number of items mapped from 5 to 4 at screen widths below 1194px, and to increase it back to 5 items above this width.
This is what I have tried so far but it causes the app to freeze if I mess around with the window size, and also initially renders 5 regardless of screen width.
This seems complex, is there an easier way to do this?
P.S - I have only included the relevant parts of the component.
// Section for loading specific number of cards
// set number of anime to map via state so it can be change in media queries
const [numberToMap, setNumberToMap] = useState(5);
//dynamically load the anime cards so that they can use the 'number' variable
const mapAnime = (number) => (
<div className='anime-list-wrapper'>
{animeData?.data?.slice(0, (number)).map((anime) => (
<AnimeCard anime={anime} key={anime.id} />
))}
</div> )
window.matchMedia('(min-width: 1194px)').addEventListener('change', () => setNumberToMap(4));
window.matchMedia('(max-width: 1195px)').addEventListener('change', () => setNumberToMap(5));
return (
<div className='container'>
<h3 className='category-title'>{categoryTitle}</h3>
{!isFetching
? <div className="wrapper">
{mapAnime(numberToMap)}
</div>
You're not cleaning up when the component unmounts. Basically you have a lot of event handlers for the change event whenever the components unmounts.
you should reverse addEventListener by using removeEventListerner("change", handler), but the requires to keep a reference to the handler.
Practically speaking, you need to set the event listeners in useEffect and return a clean up function.
const minHandler = () => setNumberToMap(4);
const maxHandler = () => setNumberToMap(5);
useEffect(() => {
const minMedia = window.matchMedia("(min-width: 1194px)");
minMedia.addEventListener("change", minHandler);
const maxMedia = window.matchMedia("(max-width: 1195px)");
maxMedia.addEventListener("change", maxHandler);
return () => {
minMedia.removeEventListener("change", minHandler);
maxMedia.removeEventListener("change", maxHandler);
};
}, []);
Or better still, you use a custom hook for matchMedia, which will take care of the cleanup.
You're updating the react state on each screen width change. So when you were messing with it in the dev tools, you got freezes because of the sheer amount of state changes. The screen won't change often, but generally it is a good idea to avoid frequent unoptimized state updates. Anyways, your listeners are listening to the same thing, so one is unnecessary. Also, update the state only when events match.
Do this and you're good:
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 1194px)');
const handler = (event: MediaQueryListEvent) => {
if (event.matches) {
setNumberToMap(4);
} else {
setNumberToMap(5);
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
How could I get the width of a JSX.Element? If I was in vanilla, I would do something like
const button = document.createElement('button');
document.body.appendChild(button)
window.getComputedStyle(button).width
Now I need to do the same, but it seems ref is null and I'm not sure even how to temporarily append to the DOM just to see what its width would be.
const button: JSX.Element = <CustomButton/>;
/// ....
Couple of things to check and consider.
where is the ref created and does it get forwarded correctly to a
valid dom element (make sure that CustomButton uses forwardRef).
you don't need to append anything in react to look at the width. all you need is ref.current.clientWidth or ref.current.getBoundingClientRect(), but ref.current has to exist in the first place :-)
if you need access to the ref.current element when your component first mounts (and not in a onClick or some other callback - then this does not apply) you'll have to use useLayoutEffect as the javascript runs before the dom is rendered so there is technically no to measure yet.
See this example:
ParentComponent.tsx
import {useState, useLayoutEffect} from 'react';
const ParentComponent = () => {
const [width, setWidth] = useState<number | null>(null);
useLayoutEffect(() => {
if(ref?.current && !width) {
const { clientWidth } = ref.current;
setWidth(clientWidth);
}
}, [ref?.current]);
console.log('width', width);
// `width` will be null at first render,
// then when CustomButton renders and <button> is created the ref will be
// updated, triggering your layout side effect that saves the
// clientWidth to the state. State change will trigger a rerender of
// ParentComponent and your console.log will finally print the width (whose
// value is stored in the state).
return <CustomButton ref={ref}/>;
};
CustomButton.tsx
import {forwardRef} from 'react';
const CustomButton = forwarRef((props, ref) => {
return (
<>
// some other stuff
<button ref={ref}/>>Click</button>
</>
);
};
The canonical way is to use a ref, and then observe it within an effect, which is called after the DOM gets rendered:
const ref = useRef();
useEffect(() => {
console.log(window.getComputedStyle(ref.current).width)
}, []);
return <button ref={ref}/>;
I am trying to update some elements on scroll by using animationFrame. I want to add an easing effect so I would like the elements to update their positions by the eased value. I figured the best way to do this would be to store all of the values in an array and update them accordingly. When each element is mounted I am sending them to a context element that adds them to the state value array.
My issue is that I cannot access the array from inside the animating function. It is available outside of the animating function but not inside. I am assuming that the animation is starting before the array is being populated but I have tried to stop and restart the animation when the blocks array changes with useEffect but to no avail.
Here is a codesandbox of the issue Example Of Issue
In the sandbox you can see in the animate() function in the ScrollContainer component I am console logging the blocks array and then after the function I am logging the same array. When you scroll the array does not log the available blocks only an empty array. But the available blocks are being logged correctly under this function.
const animate = () => {
const diff = yScroll - yCurrent;
const delta = Math.abs(diff) < 0.1 ? 0 : diff * ease;
if (delta) {
yCurrent += delta;
yCurrent = parseFloat(yCurrent.toFixed(2));
animationFrame = requestAnimationFrame(animate);
} else {
cancelAnimation();
}
console.log("Animating Blocks", blocks);
};
console.log("Available Blocks", blocks);
const addBlock = block => {
setBlocks(prev => {
return [...prev, block];
});
};
and here is how I am starting the animation
const startAnimation = () => {
if (!animationFrame) {
animationFrame = requestAnimationFrame(animate);
}
};
useEffect(() => startAnimation(), []);
Thanks.
I played with your example. It seems to me, that the problem is in the useEffect. If you add empty dependency to it, then it runs only once after the first render. There will be a second render when blocks state updates, but for the useEffect only the first state is visible because it runs only once and it uses the startAnimation with stale closure. This version of startAnimation uses the first version of the animation with the original state.
Your initial problem is solved if you add blocks to the useEffect as a dependency.
useEffect(() => {
yScroll = window.scrollY || window.pageYOffset;
yCurrent = yScroll;
startAnimation();
window.addEventListener("scroll", updateScroll);
return () => {
window.removeEventListener("scroll", updateScroll);
};
}, [blocks]);
I tried adding the animation, but is is quite choppy to me. I'm interested in your final solution. This is mime: https://codesandbox.io/s/scrolling-animation-frame-array-issue-d05wz
I use higher level animation libraries like react-spring. You can consider using something like this. I think it is much easier to use.
https://codesandbox.io/s/staging-water-sykyj
I'm trying to control the visibility of a React Component based on whether an individual is scrolling down on the component. The visibility is passed into the Fade element as the "in" property.
I've set up a listener using the UseEffect Hook, which adds the listener onMount. The actual onScroll function is supposed to update the scrollTop state (which is the current value of the height to the top of the page) and then the scrolling state (which compares the event's scroll to the top of the page with the previous state, and if the first is greater than the second, returns true).
However, for some reason the setScrollTop hook isn't working, and the scrolling state continues to stay at 0.
What am I doing wrong? Here's the full component:
export const Header = (props) => {
const classes = useStyles();
const [scrolling, setScrolling] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
const onScroll = (e) => {
setScrollTop(e.target.documentElement.scrollTop);
setScrolling(e.target.documentElement.scrollTop > scrollTop);
}
useEffect(() => {
window.addEventListener('scroll', onScroll);
},[]);
useEffect(() => {
console.log(scrollTop);
}, [scrollTop])
return (
<Fade in={!scrolling}>
<AppBar className={classes.header} position="fixed">
....
You're missing the dependencies in your hook. Try this:
useEffect(() => {
const onScroll = e => {
setScrollTop(e.target.documentElement.scrollTop);
setScrolling(e.target.documentElement.scrollTop > scrollTop);
};
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollTop]);
By moving onScroll inside the useEffect, you don't need to track it on the hook's dependencies, however since it uses scrollTop from the component's scope, you'll need to add it.
Alternatively, if for some reason you don't want to move onScroll definition inside the useEffect, you'll need to wrap onScroll in useCallback and track it in useEffect's dependency array.
In general I'd recommend adding react-hooks/exhaustive-deps to your ESlint rules
Also it's a good idea to remove the event listener in cleanup function.
Or you can use window.pageYOffset. It's a bit more understandable for me that way:
const [scrolling, setScrolling] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
function onScroll() {
let currentPosition = window.pageYOffset; // or use document.documentElement.scrollTop;
if (currentPosition > scrollTop) {
// downscroll code
setScrolling(false);
} else {
// upscroll code
setScrolling(true);
}
setScrollTop(currentPosition <= 0 ? 0 : currentPosition);
}
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollTop]);
I'm new to vuejs but I was trying to get the window size whenever I
resize it so that i can compare it to some value for a function that I
need to apply depending on the screen size. I also tried using the
watch property but not sure how to handle it so that's probably why it didn't work
methods: {
elem() {
this.size = window.innerWidth;
return this.size;
},
mounted() {
if (this.elem < 767){ //some code }
}
Put this code inside your Vue component:
created() {
window.addEventListener("resize", this.myEventHandler);
},
destroyed() {
window.removeEventListener("resize", this.myEventHandler);
},
methods: {
myEventHandler(e) {
// your code for handling resize...
}
}
This will register your Vue method on component creation, trigger myEventHandler when the browser window is resized, and free up memory once your component is destroyed.
For Vue3, you may use the code below:
mounted() {
window.addEventListener("resize", this.myEventHandler);
},
unmounted() {
window.removeEventListener("resize", this.myEventHandler);
},
methods: {
myEventHandler(e) {
// your code for handling resize...
}
}
destroyed and beforeDestroyed is deprecated in Vue3, hence you might want to use the beforeUnmount and unmounted
Simplest approach
https://www.npmjs.com/package/vue-window-size
Preview
import Vue from 'vue';
import VueWindowSize from 'vue-window-size';
Vue.use(VueWindowSize);
You would then access it normally from your components like this:
<template>
<div>
<p>window width: {{ windowWidth }}</p>
<p>window height: {{ windowHeight }}</p>
</div>
</template>
I looked at the code of that library vue-window-size, and besides the additional logic, it's just adding an event listener on window resize, and it looks like it can be instructed to debounce. Source
The critical problem for me is that my Vue SPA app does not emit a window resize event when a vue-router route changes that makes the <html> element go from 1000px to 4000px, so it's causing me all kinds of problems watching a canvas element controlled by p5.js to redraw a wallpaper using p5.resizeCanvas().
I have a different solution now that involves actively polling the page's offset height.
The first thing to be aware of is JavaScript memory management, so to avoid memory leaks, I put setInterval in the created lifecycle method and clearInterval in the beforeDestroy lifecycle method:
created() {
this.refreshScrollableArea = setInterval(() => {
const { offsetWidth, offsetHeight } = document.getElementById('app');
this.offsetWidth = offsetWidth;
this.offsetHeight = offsetHeight;
}, 100);
},
beforeDestroy() {
return clearInterval(this.refreshScrollableArea);
},
As hinted in the above code, I also placed some initial state:
data() {
const { offsetWidth, offsetHeight } = document.querySelector('#app');
return {
offsetWidth,
offsetHeight,
refreshScrollableArea: undefined,
};
},
Note: if you are using getElementById with something like this.id (ie: an element that is a child in this component), document.getElementById(this.id) will be undefined because DOM elements load outer-to-inner, so if you see an error stemming from the data instantiation, set the width/height to 0 initially.
Then, I put a watcher on offsetHeight to listen for height changes and perform business logic:
watch: {
offsetHeight() {
console.log('offsetHeight changed', this.offsetHeight);
this.state = IS_RESET;
this.setState(this.sketch);
return this.draw(this.sketch);
},
},
Conclusion: I tested with performance.now() and:
document.querySelector('#app').offsetHeight
document.getElementById('app').offsetHeight
document.querySelector('#app').getClientBoundingRect().height
all execute in about the exact same amount of time: 0.2ms, so the above code is costing about 0.2ms every 100ms. I currently find that reasonable in my app including after I adjust for slow clients that operate an order of magnitude slower than my localmachine.
Here is the test logic for your own R&D:
const t0 = performance.now();
const { offsetWidth, offsetHeight } = document.getElementById('app');
const t1 = performance.now();
console.log('execution time:', (t1 - t0), 'ms');
Bonus: if you get any performance issue due to long-running execution time on your setInterval function, try wrapping it in a double-requestAnimationFrame:
created() {
this.refreshScrollableArea = setInterval(() => {
return requestAnimationFrame(() => requestAnimationFrame(() => {
const { offsetWidth, offsetHeight } = document.getElementById(this.id);
this.offsetWidth = offsetWidth;
this.offsetHeight = offsetHeight;
}));
}, 100);
},
requestAnimationFrame itself a person should research. I will leave it out of the scope of this answer.
In closing, another idea I researched later, but am not using is to use a recursive setTimeout function with a dynamic timeout on it (ie: a timeout that decays after the page loads); however, if you consider the recursive setTimeout technique, be conscious of callstack/function-queue length and tail call optimization. Stack size could run away on you.
You can use this anywhere anytime
methods: {
//define below method first.
winWidth: function () {
setInterval(() => {
var w = window.innerWidth;
if (w < 768) {
this.clientsTestimonialsPages = 1
} else if (w < 960) {
this.clientsTestimonialsPages = 2
} else if (w < 1200) {
this.clientsTestimonialsPages = 3
} else {
this.clientsTestimonialsPages = 4
}
}, 100);
}
},
mounted() {
//callback once mounted
this.winWidth()
}