React update to state/context works sporadically - reactjs

I'm trying to build a React context object that keeps track of the window size so I can use specific components based on window.innerWidth without having to determine the value in multiple places. Ive set the following "DeviceContext" component up:
import React, { createContext, useState, useLayoutEffect } from "react"
import { size } from "../utilities/breakpoints"
export const DeviceContext = createContext()
const getSize = () => {
let width = window.innerWidth > 0 ? window.innerWidth : window.screen.width
if (width > size.huge) {
return "huge"
} else if (width >= size.large) {
return "large"
} else if (width >= size.med) {
return "med"
} else {
return "small"
}
}
export function DeviceProvider({ children }) {
let [size, setSize] = useState(getSize())
useLayoutEffect(() => {
function resize() {
setSize(getSize())
}
window.addEventListener("resize", resize)
return () => {
window.removeEventListener("resize", resize)
}
})
return (
<DeviceContext.Provider value={size}>{children}</DeviceContext.Provider>
)
}
I then wrap my Layout component in <DeviceProvider> and consume it in a menu component like this:
export const Menu = ({ children }) => {
return (
<DeviceContext.Consumer>
{screenSize => {
if (screenSize === "huge" || screenSize === "large") {
return <div>Not done yet...</div>
} else {
return <ResponsiveNav />
}
}}
</DeviceContext.Consumer>
)
}
Menu.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]),
}
export default Menu
What I expect to see is "Not done yet..." when window.innerwidth is larger than a specific value and the <ResponsivNav/> component when it is not. I expect my context to update when the window is resized (i.e. through dev tools) but it only does so sporadically. If I remove the references to "previous" in my resize method, it doesn't update at all.
Thanks in advance for any advice you might have!

By not providing a dependency array in your useLayoutEffect the event listener is being readded on every state update. Setting an empty array there will only apply the listener on mount.
useLayoutEffect(() => {
function resize() {
setSize(getSize())
}
window.addEventListener("resize", resize)
return () => {
window.removeEventListener("resize", resize)
}
}, []) // <--- empty dependency array

Related

Context API values are being reset too late in the useEffect of the hook

I have a FilterContext provider and a hook useFilter in filtersContext.js:
import React, { useState, useEffect, useCallback } from 'react'
const FiltersContext = React.createContext({})
function FiltersProvider({ children }) {
const [filters, setFilters] = useState({})
return (
<FiltersContext.Provider
value={{
filters,
setFilters,
}}
>
{children}
</FiltersContext.Provider>
)
}
function useFilters(setPage) {
const context = React.useContext(FiltersContext)
if (context === undefined) {
throw new Error('useFilters must be used within a FiltersProvider')
}
const {
filters,
setFilters
} = context
useEffect(() => {
return () => {
console.log('reset the filters to an empty object')
setFilters({})
}
}, [setFilters])
{... do some additional stuff with filters if needed... not relevant }
return {
...context,
filtersForQuery: {
...filters
}
}
}
export { FiltersProvider, useFilters }
The App.js utilises the Provider as:
import React from 'react'
import { FiltersProvider } from '../filtersContext'
const App = React.memo(
({ children }) => {
...
...
return (
...
<FiltersProvider>
<RightSide flex={1} flexDirection={'column'}>
<Box flex={1}>
{children}
</Box>
</RightSide>
</FiltersProvider>
...
)
}
)
export default App
that is said, everything within FiltersProvider becomes the context of filters.
Now comes the problem description: I have selected on one page (Page1) the filter, but when I have to switch to another page (Page2), I need to flush the filters. This is done in the useFilters hook in the unmount using return in useEffect.
The problem is in the new page (Page2), during the first render I'm still getting the old values of filters, and than the GraphQL request is sent just after that. Afterwards the unmount of the hook happens and the second render of the new page (Page2) happens with set to empty object filters.
If anyone had a similar problem and had solved it?
first Page1.js:
const Page1 = () => {
....
const { filtersForQuery } = useFilters()
const { loading, error, data } = useQuery(GET_THINGS, {
variables: {
filter: filtersForQuery
}
})
....
}
second Page2.js:
const Page2 = () => {
....
const { filtersForQuery } = useFilters()
console.log('page 2')
const { loading, error, data } = useQuery(GET_THINGS, {
variables: {
filter: filtersForQuery
}
})
....
}
Printout after clicking from page 1 to page 2:
1. filters {isActive: {id: true}}
2. filters {isActive: {id: true}}
3. page 2
4. reset the filters to an empty object
5. 2 reset the filters to an empty object
6. filters {}
7. page 2
As I mentioned in the comment it might be related to the cache which I would assume you are using something like GraphQL Apollo. It has an option to disable cache for queries:
fetchPolicy: "no-cache",
By the way you can also do that reset process within the Page Two component if you want to:
const PageTwo = () => {
const context = useFilters();
useEffect(() => {
context.setFilters({});
}, [context]);
For those in struggle:
import React, { useState, useEffect, useCallback, **useRef** } from 'react'
const FiltersContext = React.createContext({})
function FiltersProvider({ children }) {
const [filters, setFilters] = useState({})
return (
<FiltersContext.Provider
value={{
filters,
setFilters,
}}
>
{children}
</FiltersContext.Provider>
)
}
function useFilters(setPage) {
const isInitialRender = useRef(true)
const context = React.useContext(FiltersContext)
if (context === undefined) {
throw new Error('useFilters must be used within a FiltersProvider')
}
const {
filters,
setFilters
} = context
useEffect(() => {
**isInitialRender.current = false**
return () => {
console.log('reset the filters to an empty object')
setFilters({})
}
}, [setFilters])
{... do some additional stuff with filters if needed... not relevant }
return {
...context,
filtersForQuery: { // <---- here the filtersForQuery is another variable than just filters. This I have omitted in the question. I will modify it.
**...(isInitialRender.current ? {} : filters)**
}
}
}
export { FiltersProvider, useFilters }
What is done here: set the useRef bool varialbe and set it to true, as long as it is true return always an empty object, as the first render happens and/or the setFilters function updates, set the isInitialRender.current to false. such that we return updated (not empty) filter object with the hook.

Rerender only occurs on second state change,

I am hoping you guys can help me,
I think I may be missing a simple concept. I have a Component within that there is another component that has an array that is prop drilled from its parent component. In the Child component, the list is then mapped and displayed.
Now the issue is when I update the state of the Array, ie add another item to the Array using SetArray([...array, newItem]),
the useEffect in the ChildComponent will console.log the new array but the actual display does not change until I add another element to the array.
When I add another element the first element I added appears but the 2nd one doesn't.
Hopefully, that makes some sense
ChildComponent:
import React, { useState, useEffect } from "react";
////EDITOR//// List
import { Grid, Button, Card } from "#material-ui/core";
import Timestamp from "./Timestamp";
const TimestampList = ({ setTime, match, setMatchEdit, render }) => {
const [timestamps, setTimestamps] = useState([]);
useEffect(() => {
const setInit = async () => {
try {
console.log(match);
const m = await match.scores.map(player => {
console.log(player);
if (player.totalScores) {
return player.totalScores;
}
});
console.log(m);
if (m[0] && m[1]) {
setTimestamps(
[...m[0], ...m[1]].sort((a, b) => {
return a.time - b.time;
})
);
}
if (m[0] && !m[1]) {
setTimestamps(
m[0].sort((a, b) => {
return a.time - b.time;
})
);
}
if (m[1] && !m[0]) {
setTimestamps(
m[1].sort((a, b) => {
return a.time - b.time;
})
);
}
} catch (error) {
console.log(error);
}
};
if (match) {
setInit();
}
console.log(match);
}, [match]);
return (
<Grid
component={Card}
style={{ width: "100%", maxHeight: "360px", overflow: "scroll" }}
>
{timestamps && timestamps.map(timestamp => {
console.log(timestamp);
const min = Math.floor(timestamp.time / 60);
const sec = timestamp.time - min * 60;
const times = `${min}m ${sec}sec`;
return (
<Timestamp
time={times}
pointsScored={timestamp.points}
/>
);
})}
<Grid container direction='row'></Grid>
</Grid>
);
};
export default TimestampList;

Component fails to correctly render on first passes (React Hooks)

I'm trying to update a React Component (B) that renders an SVG object passed from a parent Component (A).
B then uses getSVGDocument().?getElementById("groupID") and adds handling for events based on members of the SVG group.
A also passes in a prop that indicates mouseover in a separate menu.
Simplified code in B:
export function ComponentB(props: {
overviewSvg: string
highlightKey?: string
}) {
function getElems(): HTMLElement[] {
let innerElems = new Array<HTMLElement>()
const svgObj: HTMLObjectElement = document.getElementById(
"my_svg"
) as HTMLObjectElement
if (svgObj) {
const elemGroup = svgObj.getSVGDocument()?.getElementById("elemGroup")
if (elemGroup) {
for (let i = 0; i < elemGroup.children.length; i++) {
innerElems.push(elemGroup.children[i] as HTMLElement)
}
}
}
return innerElems
}
const elems = getElems() // Also tried variations with useState and useEffect, can't seem to get the right combination...
useEffect(() => {
console.log("effect called")
console.log(elems)
elems?.forEach((elem) => {
elem.onmousedown = () => toggleColor(elem)
})
}, [elems, props.overviewSvg])
useEffect(() => {
elems?.forEach((elem) => {
if (elem.id === props.highlightKey) {
setActive(elem)
} else {
setInactive(elem)
}
})
}, [elems, props.highlightKey, props.overviewSvg])
return (
<>
<object data={props.overviewSvg} id="my_svg" />
</>
)
}
I'm not sure what the appropriate parameters for the dependencies in useEffect should be, or if this is entirely the wrong approach?
The console shows that on loading, the array of elems is often empty, in which case the onmousedown loop of course doesn't add any functions.
After the parent sets the props.highlightKey, the second function is triggered and that then triggers the first effect and adds the mousedown functions.
What should I be changing to make the component correctly render before any parent highlightKey changes?
I was able to get the desired behaviour by using useRef and applying it to the DOM element, then adding an onload behaviour to that element.
// Get a ref for the <object> element
const svgObj = useRef<HTMLObjectElement>(null)
const [elemArr, setElems] = useState(Array<HTMLElement>())
if (svgObj.current) {
svgObj.current.onload = () => {
const elems = getElems()
elems.forEach((elem) => {
elem.onmousedown = () => toggleColor(elem)
})
setElems(elems)
}
}
useEffect(() => {
elemArr.forEach((elem) => {
if (elem.id === props.highlightKey) {
setActive(elem)
} else {
setInactive(elem)
}
})
}, [elemArr, props.highlightKey])
return (
<object ref={svgObj} data={props.overviewSVG} ... />
)

In React, how do you apply componentDidMount for this type of component?

I have a React 16 application. I have a component constructed like so ...
const AddressInput = (props) => {
  const classes = useStyles();
  const { disabled, error, location, onClear, placeholder, setLocation, showMap, value } = props;
useEffect(() => {
console.log("use effect 1 ...");
if (value && placesRef?.current) {
placesRef.current.setVal(value);
if (zoom === 0) {
setZoom(16);
}
}
}, [placesRef, value, disabled, zoom]);
useEffect(() => {
console.log("use effect 2 ...");
if (location) {
if (zoom === 0) {
setZoom(16);
}
if (location.lat !== position[0]) {
setPosition([location.lat, location.lng]);
}
}
}, [location, zoom, position]);
useEffect(() => {
console.log("use effect 3 ...");
console.log("setting " + inputId + " to :" + placeholder);
if (document.getElementById(inputId)) {
document.getElementById(inputId).value = placeholder;
}
}, [])
...
console.log("after use effect:");
console.log(placeholder);
  return (
    <div style={error ? { border: "solid red" } : {}}>
...
    </div>
  );
};
export default AddressInput;
I would like to execute some code after the component has rendered. Normally I could use componentDidMount, but I'm not sure how to apply that when the component is constructed like above.
Since you're using a pure functional component you can use the useEffect hook (as of React v16.8) like this:
import React, { useEffect } from "react";
const AddressInput = (props) => {
const classes = useStyles();
const { disabled, error, location, onClear, placeholder, setLocation, showMap, value } = props;
useEffect(() => {
// your code here
}, []) // empty array acts like `componentDidMount()` in a functional component
return (
<div style={error ? { border: "solid red" } : {}}>
...
</div>
);
};
export default AddressInput;
You can read more about useEffect here:
https://reactjs.org/docs/hooks-effect.html

How to detect if screen size has changed to mobile in React?

I am developing a web app with React and need to detect when the screen size has entered the mobile break-point in order to change the state.
Specifically I need my sidenav to be collapsed when the user enters mobile mode and that is controlled with a boolean stored in the state within the component.
What I did is adding an event listener after component mount:
componentDidMount() {
window.addEventListener("resize", this.resize.bind(this));
this.resize();
}
resize() {
this.setState({hideNav: window.innerWidth <= 760});
}
componentWillUnmount() {
window.removeEventListener("resize", this.resize.bind(this));
}
EDIT:
To save state updates, I changed the "resize" a bit, just to be updated only when there is a change in the window width.
resize() {
let currentHideNav = (window.innerWidth <= 760);
if (currentHideNav !== this.state.hideNav) {
this.setState({hideNav: currentHideNav});
}
}
UPDATE: Time to use hooks!
If you're component is functional, and you use hooks - then you can use the useMediaQuery hook, from react-responsive package.
import { useMediaQuery } from 'react-responsive';
...
const isMobile = useMediaQuery({ query: `(max-width: 760px)` });
After using this hook, "isMobile" will be update upon screen resize, and will re-render the component. Much nicer!
const [isMobile, setIsMobile] = useState(false)
//choose the screen size
const handleResize = () => {
if (window.innerWidth < 720) {
setIsMobile(true)
} else {
setIsMobile(false)
}
}
// create an event listener
useEffect(() => {
window.addEventListener("resize", handleResize)
})
// finally you can render components conditionally if isMobile is True or False
Using hooks in React(16.8.0+) refering to:
https://stackoverflow.com/a/36862446/1075499
import { useState, useEffect } from 'react';
function getWindowDimensions() {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height
};
}
export default function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions());
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowDimensions;
}
This is the same as #Ben Cohen answer but after attaching your function to eventListner, also remove it on componentWillUnmount
constructor() {
super();
this.state = { screenWidth: null };
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
}
componentDidMount() {
window.addEventListener("resize", this.updateWindowDimensions());
}
componentWillUnmount() {
window.removeEventListener("resize", this.updateWindowDimensions)
}
updateWindowDimensions() {
this.setState({ screenWidth: window.innerWidth });
}
hey I just published a npm package for this issue.
Check it out https://www.npmjs.com/package/react-getscreen
import React, { Component } from 'react';
import {withGetScreen} from 'react-getscreen'
class Test extends Component {
render() {
if (this.props.isMobile()) return <div>Mobile</div>;
if (this.props.isTablet()) return <div>Tablet</div>;
return <div>Desktop</div>;
}
}
export default withGetScreen(Test);
//or you may set your own breakpoints by providing an options object
const options = {mobileLimit: 500, tabletLimit: 800}
export default withGetScreen(Test, options);
There are multiple ways to archive this first way is with CSS using this class
#media screen and (max-width: 576px) {}
any class inside this tag will only be visible when the screen is equal or less than 576px
the second way is to use the event listener
something like this
constructor(props)
{
super(props);
this.state = {
isToggle: null
}
this.resizeScreen = this.resizeScreen.bind(this);
}
componentDidMount() {
window.addEventListener("resize", this.resizeScreen());
}
resizeScreen() {
if(window.innerWidth === 576)
{
this.setState({isToggle:'I was resized'});
}
}
even with the event listener I still prefer the CSS way since we can use multiple screen sizes without further js coding.
I hope this helps!
The react-screentype-hook library allows you to do this out of the box.
https://www.npmjs.com/package/react-screentype-hook
You could use the default breakpoints it provides as follows
const screenType = useScreenType();
screenType has the following shape
{
isLargeDesktop: Boolean,
isDesktop: Boolean,
isMobile: Boolean,
isTablet: Boolean
}
Or you could even configure your custom breakpoints like this
const screenType = useScreenType({
mobile: 400,
tablet: 800,
desktop: 1000,
largeDesktop: 1600
});
For Next.js Here is a custom hook
import { useState, useEffect } from 'react';
export default function useScreenWidth() {
const [windowWidth, setWindowWidth] = useState(null);
const isWindow = typeof window !== 'undefined';
const getWidth = () => isWindow ? window.innerWidth : windowWidth;
const resize = () => setWindowWidth(getWidth());
useEffect(() => {
if (isWindow) {
setWindowWidth(getWidth());
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}
//eslint-disable-next-line
}, [isWindow]);
return windowWidth;
}
In a component, it returns the width size of the viewport, which can then be compared with a given numeric value
const widthSize = useScreenWidth()
const mobileWidth = 400
if(widthSize > mobileWidth){
//logic for desktop
}
if(widthSize <= mobileWidth){
//logic for mobile
}
In Functional Component, we can detect screen size by useTheme and useMediaQuery.
const theme = useTheme();
const xs = useMediaQuery(theme.breakpoints.only('xs'));
const sm = useMediaQuery(theme.breakpoints.only('sm'));
const md = useMediaQuery(theme.breakpoints.only('md'));
const lg = useMediaQuery(theme.breakpoints.only('lg'));
const xl = useMediaQuery(theme.breakpoints.only('xl'));

Resources