Need to update React component on window resize - reactjs

I am trying to access the window object inside of a React.js component as I want to create a state which holds the dynamic innerWidth value of the window object. I was able to make it work when the page gets refreshed but not when I resize the page with the dev tools dynamically.
Here is the code that works for me on refresh:
const About = () => {
const [bioType, setBioType] = useState("");
const truncateText = () =>
window.innerWidth > 1024 ? setBioType("desktop") : setBioType("mobile");
useEffect(() => {
truncateText();
});
return ({
bioType === 'desktop' ? ... : ....
})
}
However, when I resize the web page with Dev Tools, it doesn't work. Could someone give me a hint? Thanks.`

Changing the windows width doesn't cause React to react to the change, and re-render. You need to use an event handler to listen to the resize event, use a ResizeObserver or use MatchMedia, and listen to the changes.
Example with MatchMedia:
const { useState, useEffect } = React;
const MIN_WIDTH = 600;
const getBioType = matches => matches ? 'desktop' : 'mobile';
const About = () => {
const [bioType, setBioType] = useState(() => getBioType(window.innerWidth > MIN_WIDTH));
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${MIN_WIDTH}px)`);
const handler = e => setBioType(getBioType(e.matches));
mql.addEventListener('change', handler);
return () => {
mql.removeEventListener('change', handler);
};
}, []);
return bioType;
}
ReactDOM
.createRoot(root)
.render(<About />);
<script crossorigin src="https://unpkg.com/react#18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.development.js"></script>
<div id="root"></div>

Related

How to register only once in react useEffect and get useState changes

I have a react hook component which needs to register to an outside event as follows:
const DefaultRateSection: React.FC<{
registerOnSaveEvent: (fn: () => void) => void;
}> = ({registerOnSaveEvent}) => {
const [serviceName, setServiceName] = useState('the name');
const serviceNameInput = useRef<input>(null);
useEffect(
() => {
return registerOnSaveEvent(() => {
if (!serviceName) {
serviceNameInput.current?.focus();
}
});
},
[registerOnSaveEvent]
);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue)
}}
/>
);
};
The registerOnSaveEvent is an API that i cannot change and i do not have an unsubscribe method, therefore i need to register to it only once. However, when it fires (from outside the component) i'm receiving the initial value of serviceName and not the updated one. I know it happens because i'm not calling useEffect after the change, but I need to avoid multiple registrations.
How can i achieve this?
TL-TR: The short answer is to use another ref so that the arrow function can access the latest rendered value of service name.
const serviceNameRef = useRef();
serviceNameRef.current = serviceName;
// use serviceNameRef.current in the arrow function
This code will NOT work
// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;
const DefaultRateSection = ({ registerOnSaveEvent }) => {
const [serviceName, setServiceName] = useState("the name");
const serviceNameInput = useRef();
useEffect(() => {
return registerOnSaveEvent(() => {
console.log(serviceName)
});
}, [registerOnSaveEvent]);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue);
}}
/>
);
};
const App = () => {
const callbackRef = useRef();
function registerOnSaveEvent(callback) {
callbackRef.current=callback
}
function execCallback() {
callbackRef.current();
}
return <div>
<h2>This will not work</h2>
<p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
<p>The callback will not see the new value of the input</p>
<DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
<button onClick={execCallback}>RegisterOnSaveEvent</button>
</div>;
};
ReactDOM.render(
<App/>,
document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>
This code will work
// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;
const DefaultRateSection = ({ registerOnSaveEvent }) => {
const [serviceName, setServiceName] = useState("the name");
const serviceNameInput = useRef();
// use a ref for the service name and
// update it with the serviceName state on every render
const serviceNameRef = useRef();
serviceNameRef.current = serviceName;
useEffect(() => {
return registerOnSaveEvent(() => {
console.log(serviceNameRef.current)
});
}, [registerOnSaveEvent]);
return (
<input
ref={serviceNameInput}
value={serviceName}
onChange={(event) => {
const newValue = event.target.value;
setServiceName(newValue);
}}
/>
);
};
const App = () => {
const callbackRef = useRef();
function registerOnSaveEvent(callback) {
callbackRef.current=callback
}
function execCallback() {
callbackRef.current();
}
return <div>
<h2>This will work</h2>
<p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
<p>The callback will not see the new value of the input</p>
<DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
<button onClick={execCallback}>RegisterOnSaveEvent</button>
</div>;
};
ReactDOM.render(
<App/>,
document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>
Explanation
When the useEffect is executed it creates an arrow function. This arrow function references the const serviceName which is the initial value. This is the value that the arrow function sees. When you enter something in the input field you call the setServiceName which changes the state and triggers a rerender. The rendering itself is nothing but a function call. So when the component is rerendered the useState returns the state and you assign it to a brand new local const named serviceName. This is NOT the same as the one that the arrow function references. Thus the arrow function will always see the value of serviceName when it was created.
To solve this problem I use another ref for the serviceName called serviceNameRef and update that ref with the serviceName state on every rendering. Since useRef returns the same instance of serviceRefName on each call, it is the same instance as the one the arrow function uses. That's how it works.

Modifying user input value in React controlled component

I've been stuck on this error for a long time, so I would appreciate some help. Here is a minimally reproducible exmaple:
import "./App.css";
import React, { useState } from "react";
import $ from "jquery";
function App() {
const [value, setValue] = useState("");
const focusHandler = () => {
$("input").on("keypress", (e) => {
let copy = value;
if (e.key === ".") {
e.preventDefault();
copy += " ";
setValue(copy);
}
});
};
const blurHandler = (event) => {
$("input").off("keypress");
setValue(event.target.value);
};
const changeHandler = (event) => {
setValue(event.target.value);
};
return (
<div>
<input
value={value}
onFocus={focusHandler}
onBlur={blurHandler}
onChange={changeHandler}
/>
</div>
);
}
export default App;
On input focus, I'm adding an event listener to look for a . keypress and append a tab (4 spaces) to the input if it is pressed. But when I press . multiple times, the input gets stuck at the first tab, and doesn't move any further (e.g. input permanenetly shows 4 spaces). Using console.log shows me that the value state doesn't seem to be updating in focusHandler and reverts to the original value ("").
An important note is that switching to a class-based component with this.state makes it work. Any insight as to why this is happening?
As mentioned in the comments, jQuery is the wrong tool for the job. Bringing in jQuery is the same as calling DOM methods directly: it's circumventing React and adding additional listeners on top of the ones React already gives you. You can expect misbehavior if you're setting state from multiple handlers unrelated to React. Once you're in React, use the tools it gives you (refs, effects, handlers) to solve the problem.
Worst case scenario is when an approach appears to work, then fails in production, on other people's machines/browsers, when refactoring from classes to hooks or vice-versa, in different versions of React, or for 1 out of 1000 users. Staying well within the API React gives you better guarantees that your app will behave correctly.
Controlled component
For manipulating the input value, you can use both onKeyDown and onChange listeners. onKeyDown fires first. Calling event.preventDefault() inside of onKeyDown will block the change event and ensure only one call to setState for the controlled input value occurs per keystroke.
The problem with this the input cursor moves to the end after the component updates (see relevant GitHub issue). One way to deal with this is to manually adjust the cursor position when you've made an invasive change to the string by adding state to keep track of the cursor and using a ref and useEffect to set selectionStart and selectionEnd properties on the input element.
This causes a brief blinking effect due to asynchrony after the render, so this isn't a great solution. If you're always appending to the end of the value, you assume the user won't move the cursor as other answers do, or you want the cursor to finish at the end, then this is a non-issue, but this assumption doesn't hold in the general case.
One solution is to use useLayoutEffect which runs synchronously before the repaint, eliminating the blink.
With useEffect:
const {useEffect, useRef, useState} = React;
const App = () => {
const [value, setValue] = useState("");
const [cursor, setCursor] = useState(-1);
const inputRef = useRef();
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {selectionStart: start} = event.target;
const {selectionEnd: end} = event.target;
const v = value.slice(0, start) + pad + value.slice(end);
setValue(v);
setCursor(start + pad.length);
}
};
const onChange = event => {
setValue(event.target.value);
setCursor(-1);
};
useEffect(() => {
if (cursor >= 0) {
inputRef.current.selectionStart = cursor;
inputRef.current.selectionEnd = cursor;
}
}, [cursor]);
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
ref={inputRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react#18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.development.js"></script>
<div id="app"></div>
With useLayoutEffect:
const {useLayoutEffect, useRef, useState} = React;
const App = () => {
const [value, setValue] = useState("");
const [cursor, setCursor] = useState(-1);
const inputRef = useRef();
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {selectionStart: start} = event.target;
const {selectionEnd: end} = event.target;
const v = value.slice(0, start) + pad + value.slice(end);
setValue(v);
setCursor(start + pad.length);
}
};
const onChange = event => {
setValue(event.target.value);
setCursor(-1);
};
useLayoutEffect(() => {
if (cursor >= 0) {
inputRef.current.selectionStart = cursor;
inputRef.current.selectionEnd = cursor;
}
}, [cursor]);
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
ref={inputRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react#18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.development.js"></script>
<div id="app"></div>
Uncontrolled component
Here's another attempt using an uncontrolled component. This doesn't have the blinking problem because the DOM element's .value property is synchronously set at the same time as the .selectionStart property and is rendered in the same repaint.
const App = () => {
const pad = ". ";
const onKeyDown = event => {
if (event.code === "Period") {
event.preventDefault();
const {target} = event;
const {
value, selectionStart: start, selectionEnd: end,
} = target;
target.value = value.slice(0, start) +
pad + value.slice(end);
target.selectionStart = start + pad.length;
target.selectionEnd = start + pad.length;
}
};
return (
<div>
<p>press `.` to add 4 spaces:</p>
<input
onKeyDown={onKeyDown}
/>
</div>
);
};
ReactDOM.createRoot(document.querySelector("#app"))
.render(<App />);
input {
width: 100%;
}
<script crossorigin src="https://unpkg.com/react#18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.development.js"></script>
<div id="app"></div>
Don't mix direct DOM manipulation, whether that's vanilla JavaScript or jQuery, with React. There is no need to add an event handler with jQuery here, because your methods are already event handlers. Just use them directly:
const focusHandler = (e) => {
// handle the event here!
}
My solution:
const changeHandler = (event) => {
const key = event.nativeEvent.data;
if (key === ".") {
event.preventDefault();
const initialValue = event.target.value.split(".")[0];
console.log(initialValue);
setValue(initialValue + " ");
} else {
setValue(event.target.value);
}
};

How to solve "addEventListener" TypeError of null in ReactJS?

I am trying to use Google's model viewer to load 3D models. It has a button. But I want that button to be visible after the model is completely loaded. So, I used to use this Vanilla JavaScript code
<script>
const modelViewer = document.querySelector("model-viewer");
const button = document.getElementById("my_ar_button");
modelViewer.addEventListener("load", function() {
button.style.display = "block";
});
</script>
Now I am planning to use it in ReactJS and this is what it looks like
const modelViewer = document.querySelector("model-viewer");
const button = document.getElementById("my_ar_button");
modelViewer.addEventListener("load", function() {
button.style.display = "block";
});
<model-viewer
src="https://modelviewer.dev/shared-assets/models/reflective-sphere.gltf"
alt="A 3D model of an astronaut"
ar ar-modes="webxr scene-viewer quick-look"
ar-scale="auto"
quick-look-browsers="safari chrome"
ios-src="https://modelviewer.dev/shared-assets/models/Astronaut.usdz"
loading="eager"
poster="https://modelviewer.dev/assets/poster-astronaut.png"
autoplay
camera-controls
>
<button id="my_ar_button" class="my_ar_button" slot="ar-button">Show AR</button>
</model-viewer>
My CSS
.my_ar_button {
display: none;
}
I have added model-viewer using script tag
<Helmet>
<script
type="module"
src="https://unpkg.com/#google/model-viewer/dist/model-viewer.min.js"
async
>
</script>
</Helmet>
But it is giving me this error
TypeError: Cannot read property 'addEventListener' of null
You need to control <model-viewer> element within a React wrapper. See react-model-viewer, this is is their wrapper
const useModelLoader = (type, src): ModelData => {
const loader = useMemo(() => getLoader(type), [type]);
const [model, setModel] = useState(undefined);
const [modelCenter, setModelCenter] = useState<THREE.Vector3>(undefined);
const [error, setError] = useState(undefined);
const [progress, setProgress] = useState(0);
useEffect(() => {
loader.load(
// resource URL
src,
// called when the resource is loaded
model => setModel(model),
// called while loading is progressing
({ loaded, total }) => setProgress((loaded / total) * 100),
// called when loading has errors
error => setError(error)
);
}, [loader, src]);
// get the center of the model
useEffect(() => {
if (!model) {
return;
}
const box = new THREE.Box3();
box.setFromObject(model.scene);
const center = new THREE.Vector3();
box.getCenter(center);
setModelCenter(center);
}, [model]);
return { model, modelCenter, progress, error };
};
The 2nd useEffect() depends on model, so you can do the same with the button. This would be the React equivalent of modelViewer.addEventListener("load", function() {
const [showButton, setShowButton] = useState(false);
useEffect(() => {
if (model) { // when model has a value, the loader has completed
setShowButton(true); // now show the button
}
}, [model]);
const Button = showButton ? <button...> : null; // use this to wrap <button>

Responsive sidebar using react hooks

I'm trying to debug an issue that I'm having with my sidebar but can't figure out what I'm doing wrong. The sidebar is correctly collapsing based on the innerWidth but when I am on mobile view on the first load the sidebar is expanded rather than collapse as suppose to be.
Any help that explains to me what is wrong would be great.
Thanks a lot
Here is my snippet:
export default function Sidebar() {
const location = useLocation();
let { pathname } = location;
const [isNavOpen, setIsNavOpen] = useState(true);
useEffect(() => {
window.addEventListener("resize", () => {
if (window.innerWidth <= 767) {
setIsNavOpen(false);
}
else if (window.innerWidth >= 767) {
setIsNavOpen(true);
}
});
});
return (
<div className="menu-bar">
<Menu
width={210}
isOpen={isNavOpen}
noOverlay
pageWrapId={"page-wrap"}
outerContainerId={"outer-container"}
disableAutoFocus
disableCloseOnEsc
>
Your initial state is true, so when the app starts, the sidebar is open. As long as you don't resize, the event handler is not called.
Extract the logic that defines the state of isNavOpen to a function, and call it to create the initial value, and then when the window is resized:
const { useState, useEffect } = React;
const shouldBeOpen = () => window.innerWidth > 767
function Sidebar() {
const [isNavOpen, setIsNavOpen] = useState(shouldBeOpen);
useEffect(() => {
window.addEventListener("resize", () => {
setIsNavOpen(shouldBeOpen());
});
}, []);
return isNavOpen ? 'open' : 'close'
}
ReactDOM.render(
<Sidebar />,
root
);
<script crossorigin src="https://unpkg.com/react#17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#17/umd/react-dom.development.js"></script>
<div id="root"></div>
In addition, in this case I prefer the use of window.matchMedia(), which is the JS equivalent to CSS media queries. I've created a useMatchMedia hook which you can see in this answer.

Why useEffect doesn't trigger on document change?

I am writing custom hook to set new locale every time when the HTML lang attribute changes, but it seems the useEffect hook doesn't fire when the document.documentElement.lang is changed with javascript. I know how to solve this, my question is why does it behave like this?
export const useLocale = (): LocaleObject => {
const [lang, setLang] = useState<string>(document.documentElement.lang);
useEffect(() => {
setLang(document.documentElement.lang);
}, [document.documentElement.lang]); // useEffect is not triggered when document.documentElement.lang changes
return locale[lang];
};
As #DennisVash written in his comment:
Why? Because the change to document.documentElement.lang won't trigger
the hook. Only a render will trigger the hook, and if lang changed the
callback will be executed.
However, since changing the property actually changes the lang attribute value in the DOM, you can use a MutationObserver to track the lang attribute values.
I've created a custom useMutationObserver hook to track mutations in the DOM, and based useLocale on it.
const { useRef, useEffect, useState, useCallback } = React;
const useMutationObserver = (domNodeSelector, observerOptions, cb) => {
useEffect(() => {
const targetNode = document.querySelector(domNodeSelector);
const observer = new MutationObserver(cb);
observer.observe(targetNode, observerOptions);
return () => {
observer.disconnect();
};
}, [domNodeSelector, observerOptions, cb]);
}
const options = { attributes: true };
const useLocale = () => {
const [lang, setLang] = useState(document.documentElement.lang);
const handler = useCallback(mutationList => {
mutationList.forEach(mutation => {
if(mutation.type !== 'attributes' || mutation.attributeName !== 'lang') return;
setLang(document.documentElement.lang);
});
}, []);
useMutationObserver('html', options, handler);
return lang; // locale[lang]
};
const Demo = () => {
const locale = useLocale();
return <div>{locale}</div>;
};
document.documentElement.lang = 'en'; // base lang
ReactDOM.render(
<Demo />,
root
);
// example - changing the lang
setTimeout(() => document.documentElement.lang = 'fr', 1000);
setTimeout(() => document.documentElement.lang = 'ru', 3000);
<script crossorigin src="https://unpkg.com/react#17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#17/umd/react-dom.development.js"></script>
<div id="root"></div>

Resources