Gatsby: React conditional rendering based on window.innerWidth misbehaving - javascript

Conditional rendering of components based on window.innerWidth seems to not work as intended just in the production build of Gatsby based website.
The hook I am using to check the viewport's width, with the additional check for the window global to avoid Gatsby-node production build errors, is the following:
import { useState, useEffect } from 'react'
const useWindowWidth = () => {
const windowGlobal = typeof window !== 'undefined'
if(windowGlobal) {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
return width
}
}
export default useWindowWidth
Then in my actual component I do the following:
IndexPage.Booking = () => {
const windowWidth = useWindowWidth()
return (
<div className="section__booking__wrapper">
{ windowWidth <= mediaQueries.lg && <IndexPage.Cta /> }
<div className="section__booking-bg" style={{ backgroundImage: `url(${bg})` }}>
{ windowWidth > mediaQueries.lg && <IndexPage.Cta /> }
</div>
</div>
)
}
It works as it should in development but the production build fails to render:
<div className="section__booking-bg" style={{ backgroundImage: `url(${bg})` }}>
When resizing the window below the mediaQueries.lg (1024) it then triggers the actual normal behaviour or conditionally rendering mobile and desktop versions of the component.
To doublecheck if it was because the render triggers on just the resize event (which it doesn't as it works on load in development environment) I also simply, from within the hook console.log() the return value and it gets printed, in production correctly on load.
There are also no errors or warnings in the production or development build whatsoever.
Edit as per #Phillip 's suggestion
const useWindowWidth = () => {
const isBrowser = typeof window !== 'undefined'
const [width, setWidth] = useState(isBrowser ? window.innerWidth : 0)
useEffect(() => {
if (!isBrowser) return false
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
return width
}
It now works just when you resize it, once, under the mediaQueries.lg threshold and then it works flawlessly across desktop and mobile but not on load.

I had a similar problem to this and haven't found a solution, but a work around. Put the following at the start of your render:
if (typeof window === `undefined`) {
return(<></>);
}
What I think is happening is that Gatsby is building the page with a style based off the window width (which will be 0 / undefined). Then it's not updating the style in the DOM once the page loads as it thinks it has already performed that action. I think this is a small bug in Gatsby maybe?
Either way, the above renders your component blank during the build, forcing it to fully respect all logic when the page loads. Hopefully that provides a solution albeit not a satisfying/complete explanation :)

I'm guessing it is too late to answer but calling handleResize before adding the event listener should work. Here is a code I used for same purpose:
useEffect(() => {
setWidth(window.innerWidth);
window.addEventListener("resize", () => {
setWidth(window.innerWidth);
});
return () => {
window.removeEventListener("resize", () => {});
};
}, []);

Don’t call Hooks inside loops, conditions, or nested functions (from React docs)
React Hooks must run in the exact same order on every render. Move your condition into the useEffect callback:
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize)
};
});

Related

How to remove event listener inside useCallback hook

I want to create a generic react hook that will add a scroll event to the element and return a boolean indicating that the user has scrolled to the top of the element.
Now, the problem is this element might not be visible right away. Hence I'm not able to use useEffect. As I understand in that situation it is advised to use useCallback
So I did, and it works:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const setRef = useCallback((element) => {
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (element) {
element.addEventListener("scroll", handleScroll);
}
ref.current = element;
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
I can use my hook like this:
const { hasScrolled, scrollingElementRef } = useHasScrolled();
....
return <div ref={scrollingElementRef}>....
However, the problem is, I don't know how to remove the event listener. With the useEffect hook, it's pretty straightforward - you just return the cleanup function.
Here's the codesandbox, if you want to check the implementation: https://codesandbox.io/s/pedantic-dhawan-83fdw3
Expected behavior - when node is removed from DOM - event listeners will be also removed and collected by GC.
But
Codesandbox example is a bit tricky, React treats
<div>Loading...</div>
and
<div className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
as a same div, same object, just with different props (className and children), so when div.scrollingDiv is replaced by conditional rendering to div(loading) - event listeners are still there and accumulating.
This behavior can be fixed as is by using keys.
{loading ? (
<div key="div1">Loading...</div>
) : (
<div key="div2" className="scrollingDiv" ref={scrollingElementRef}>
<h1>Hello, I've finally loaded!</h1>
<Lorem />
</div>
)}
In that way event listeners will be removed as expected.
Another solution is to add 1 more useRef and useEffect to the custom hook to store and execute actual unsubscribe function:
function useHasScrolled() {
const [hasScrolled, setHasScrolled] = useState(false);
const ref = useRef(null);
const unsubscribeRef = useRef(null);
const setRef = useCallback((element) => {
const eventName = "scroll";
const handleScroll = (e) => {
setHasScrolled(e.target.scrollTop !== 0);
};
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
if (element) {
element.addEventListener(eventName, handleScroll);
unsubscribeRef.current = () => {
console.log("removeEventListener called on: ", element);
element.removeEventListener(eventName, handleScroll);
};
ref.current = element;
} else {
unsubscribeRef.current = null;
ref.current = null;
}
}, []);
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, []);
return {
hasScrolled,
scrollingElementRef: setRef
};
}
That code will work without adding key.
Utility code for Chrome dev console to count scroll listeners:
Array.from(document.querySelectorAll('*'))
.reduce(function(pre, dom){
var clks = getEventListeners(dom).scroll;
pre += clks ? clks.length || 0 : 0;
return pre
}, 0)
Updated codesandbox: https://codesandbox.io/s/angry-einstein-6fb1u4?file=/src/App.js

If I refresh the page matchMedia doesn't work

I'm using matchMedia in React to collapse my SideBar when the page is resizing. But the problem is if I refresh the page, my sidebar is open not closed. So if I want to collapse my SideBar I need to resize the page again or use the close button.
const layout = document.getElementById('home-layout');
const query = window.matchMedia('(max-width: 765px)');
query.onchange = (evt) => {
if( query.matches ) {
changeMenuMinified(true);
layout.classList.add('extended-layout');
}
else {
changeMenuMinified(false);
layout.classList.remove('extended-layout');
}
};
query.onchange();
};
useEffect(() => {
window.addEventListener('resize', handleResize);
});
If I remove addEventListener it works, I can reload the page and my sidebar stays closed but if I try to open the sidebar with a button, the sidebar closes quickly
const handleResize = () => {
const layout = document.getElementById('home-layout');
const query = window.matchMedia('(max-width: 765px)');
query.onchange = (evt) => {
if( query.matches ) {
changeMenuMinified(true);
layout.classList.add('extended-layout');
}
else {
changeMenuMinified(false);
layout.classList.remove('extended-layout');
}
};
query.onchange();
};
useEffect(() => {
handleResize()
});
sideBar
Some stuff to consider here:
Initialize your state with the current matching value
Remove listener on effect cleanup function
Don't forget the useEffect dependency array to avoid your code being executed on each render.
You can find a working example here -> https://codesandbox.io/s/stack-72619755-lpwh6m?file=/src/index.js:0-613
const query = window.matchMedia('(max-width: 765px)')
const App = () => {
const [minified, changeMenuMinified] = useState(query.matches)
useEffect(() => {
const resizeHandler = () => {
if (query.matches) {
changeMenuMinified(true)
} else {
changeMenuMinified(false)
}
}
query.addEventListener("change", resizeHandler);
return () => query.removeEventListener("change", resizeHandler);
})
return <p>{minified ? 'minified' : 'expanded'}</p>
}
That's because you need to have both in order to work, on load and also on reside, for that you can just do so:
Notice I added that empty dependencies array.
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
},[]);

why i get different value width window using react js?

example:
resize using react js
this is my code:
import React, { useState, useEffect } from 'react';
const getWidthWindow = () => {
const [widthWindow, setWidthWindow] = useState(null)
const updateDimensions = () => {
setWidthWindow(window.screen.width)
}
useEffect(() => {
console.log(widthWindow)
setWidthWindow(window.screen.width)
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
}, [widthWindow])
}
export default getWidthWindow;
I want to get the window width value but the result is like it doesn't match the window size so how to fix it?
Your code is correct but the logging isn't.
Add a hook to log the dimensions when it updates:
useEffect(() => {
console.log(windowDimensions)
}, [windowDimensions])
Working codesandbox.
I go with the above answer of adding windowDimensions to the useEffect's Dependency array but I like to add up little sugar on top of it..
On Resize, the event gets triggered continuously and impacts performance a bit..
So, I have implemented throttling to improve the performance..
Answer for your updated question: Stackblitz link
const GetWidthWindow = () => {
const [widthWindow, setWidthWindow] = useState(window.innerWidth);
useEffect(() => {
let throttleResizeTimer = null;
function handleResize() {
clearTimeout(throttleResizeTimer);
throttleResizeTimer = setTimeout(
() => setWidthWindow(window.innerWidth),
500
);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [widthWindow]);
return <p>{JSON.stringify(widthWindow)}</p>;
};
export default GetWidthWindow;
Answer for your old question:
useEffect(() => {
// implement throttle for little performance gain
let throttleResizeTimer = null;
function handleResize() {
clearTimeout(throttleResizeTimer);
throttleResizeTimer = setTimeout(
() => setWindowDimensions(getWindowDimensions()),
500
);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); }, [windowDimensions]);

React useState doesn't update in window events

State does get set on the scroll, but logged from the eventlistener, it seems to be stuck at the initial value.
I guess it's something to do with scrolling being set when the side effect's defined, but how could I trigger a state change from a scroll otherwise? Same goes for any window event I presume.
Here's a codesandbox example: https://codesandbox.io/s/react-test-zft3e
const [scrolling, setScrolling] = useState(false);
useEffect(() => {
window.addEventListener("scroll", () => {
console.log(scrolling);
if (scrolling === false) setScrolling(true);
});
}, []);
return (
<>
scrolling: {scrolling}
</>
);
So your anonymous function is locked on initial value of scrolling. It's how closures works in JS and you better find out some pretty article on that, it may be tricky some time and hooks heavily rely on closures.
So far there are 3 different solutions here:
1. Recreate and re-register handler on each change
useEffect(() => {
const scrollHandler = () => {
if (scrolling === false) setScrolling(true);
};
window.addEventListener("scroll", scrollHandler);
return () => window.removeEventListener("scroll", scrollHandler);
}, [scrolling]);
while following this path ensure your are returning cleanup function from useEffect. It's good default approach but for scrolling it may affect performance because scroll event triggers too often.
2. Access data by reference
const scrolling = useRef(false);
useEffect(() => {
const handler = () => {
if (scrolling.current === false) scrolling.current = true;
};
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
return (
<>
scrolling: {scrolling}
</>
);
downside: changing ref does not trigger re-render. So you need to have some other variable to change it triggering re-render.
3. Use functional version of setter to access most recent value
(I see it as preferred way here):
useEffect(() => {
const scrollHandler = () => {
setScrolling((currentScrolling) => {
if (!currentScrolling) return true;
return false;
});
};
window.addEventListener("scroll", scrollHandler);
return () => window.removeEventListener("scroll", scrollHandler);
}, []);
Note Btw even for one-time use effect you better return cleanup function anyway.
PS Also by now you don't set scrolling to false, so you could just get rid of condition if(scrolling === false), but sure in real world scenario you may also run into something alike.
The event listener callback is only initialized once
This means that the variable at that moment are also "trapped" at that point, since on rerender you're not reinitializing the event listener.
It's kind of like a snapshot of the on mount moment.
If you move the console.log outside you will see it change as the rerenders happen and set the scroll value again.
const [scrolling, setScrolling] = useState(false);
useEffect(() => {
window.addEventListener("scroll", () => {
if (scrolling === false) setScrolling(true);
});
}, []);
console.log(scrolling);
return (
<>
scrolling: {scrolling}
</>
);
A solution that has personally served me well when I need to access a state (getState and setState) in an eventListener, without having to create a reference to that state (or all the states it has), is to use the following custom hook:
export function useEventListener(eventName, functionToCall, element) {
const savedFunction = useRef();
useEffect(() => {
savedFunction.current = functionToCall;
}, [functionToCall]);
useEffect(() => {
if (!element) return;
const eventListener = (event) => savedFunction.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
What I do is make a reference to the function to be called in the eventListener. in the component where I need an eventLister, it will look like this:
useEventListener("mousemove", getAndSetState, myRef.current); //myRef.current can be directly the window object
function getAndSetState() {
setState(state + 1);
}
I leave a codesandbox with a more complete code

React hooks - setState is not updating the state properties to show sidebar

I have a event when you resize the window will show desktop sidebar or mobile sidebar. if window is less than But there are variables that aren't updated immediately to show sidebar if I'm in desktop window, I could that with class
I've created a sandbox https://codesandbox.io/s/sidebar-hooks-8oefi
to see the code, I have the class component in App_class.js which if I replace in App it works, but with hooks (App_hooks.js file, by default in App.js) I can't make it works
Thanks for your attention. I’m looking forward to your reply.
with class I could do that using:
if (isMobile !== wasMobile) {
this.setState({
isOpen: !isMobile
});
}
const App = props => {
//minicomponent to update width
const useListenResize = () => {
const [isOpen, setOpen] = useState(false);
const [isMobile, setMobile] = useState(true);
//const [previousWidth, setPreviousWidth] = useState( -1 );
let previousWidth = -1;
const updateWidth = () => {
const width = window.innerWidth;
const widthLimit = 576;
let newValueMobile = width <= widthLimit;
setMobile(isMobile => newValueMobile);
const wasMobile = previousWidth <= widthLimit;
if (isMobile !== wasMobile) {
setOpen(isOpen => !isMobile);
}
//setPreviousWidth( width );
previousWidth = width;
};
useEffect(() => {
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
// eslint-disable-next-line
}, []);
return isOpen;
};
const [isOpen, setOpen] = useState(useListenResize());
const toggle = () => {
setOpen(!isOpen);
};
return (
<div className="App wrapper">
<SideBar toggle={toggle} isOpen={isOpen} />
<Container fluid className={classNames("content", { "is-open": isOpen })}>
<Dashboard toggle={toggle} isOpen={isOpen} />
</Container>
</div>
);
};
export default App;
with setState didn't work
The issue is after setting isMobile value here,
setMobile(isMobile => newValueMobile);
You are immediately refering that value,
if (isMobile !== wasMobile) {
setOpen(isOpen => !isMobile);
}
Due to async nature of setState, your are getting previous value of isMobile here all the times.
To make this work you need to make some change to your code.
You are directly mutating previousWidth value, you should have previousWidth in a state and use setter function to change the value.
const [previousWidth, setPreviousWidth] = useState(-1);
setPreviousWidth(width); //setter function
You cannot get value immediately after setState. You should use another useEffect with isMobile and previousWidth as dependency array.
useEffect(() => {
const wasMobile = previousWidth <= widthLimit;
if (isMobile !== wasMobile) {
setOpen(isOpen => !isMobile);
}
}, [isMobile, previousWidth]);
Demo
Since updateWidth is registered once on component mount with useEffect, then it will refer to a stale state (isMobile), which is always true, therefore, setOpen(isOpen => !isMobile) will never work.
EDIT: The following code illustrates a simpler version of your issue:
const Counter = () => {
const [count, setCount] = useState(0)
const logCount = () => {
console.log(count);
}
useEffect(() => {
window.addEventListener('resize', logCount);
}, [])
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
)
}
If you run this code, you will notice that even if you change the value of count by clicking on the button, you will always get the initial value when resizing the browser (check the console), and that's because logCount refer to a state that was obtained at the moment it was defined.
#ravibagul91 answer works, because it access the state from useEffect, and not from the event handler.
You may wanna check: React hooks behaviour with event listener and Why am I seeing stale props or state inside my function?, it will give you a better idea of what's going on.

Categories