React hook for page scroll position causing re-renders on scroll - reactjs

I'm using a React hook to track the scroll position on a page. The hook code is as follows:
import { useLayoutEffect, useState } from 'react';
const useScrollPosition = () => {
const [scrollPosition, setScrollPosition] = useState(window.pageYOffset);
useLayoutEffect(() => {
const updatePosition = () => {
setScrollPosition(window.pageYOffset);
};
window.addEventListener('scroll', updatePosition);
return () => window.removeEventListener('scroll', updatePosition);
}, []);
return scrollPosition;
};
export default useScrollPosition;
I then use this in various ways, for example in this component where a class is applied to an element if the page has scrolled more than 10px:
const Component = () => {
const scrollPosition = useScrollPosition();
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const newScrolled = scrollPosition > 10;
if (newScrolled !== scrolled) {
setScrolled(newScrolled);
}
}, [scrollPosition]);
return (
<div
className={clsx(style.element, {
[style.elementScrolled]: scrolled,
})}
>
{children}
</div>
);
};
This all works and does what I'm trying to achieve, but the component re-renders continuously on every scroll of the page.
My understanding was that by using a hook to track the scroll position, and by using useState/useEffect to only update my variable "scrolled" in the event that the scroll position passes that 10px threshold, the component shouldn't be re-rendering continuously on scroll.
Is my assumption wrong? Is this behaviour expected? Or can I improve this somehow to prevent unnecessary re-rendering? Thanks

another idea is to have your hook react only if the scroll position is over 10pixel :
import { useEffect, useState, useRef } from 'react';
const useScrollPosition = () => {
const [ is10, setIs10] = useState(false)
useEffect(() => {
const updatePosition = () => {
if (window.pageYOffset > 10) {setIs10(true)}
};
window.addEventListener('scroll', updatePosition);
return () => window.removeEventListener('scroll', updatePosition);
}, []);
return is10;
};
export default useScrollPosition;
import React, { useEffect, useState } from "react";
import useScrollPosition from "./useScrollPosition";
const Test = ({children}) => {
const is10 = useScrollPosition();
useEffect(() => {
if (is10) {
console.log('10')
}
}, [is10]);
return (
<div
className=''
>
{children}
</div>
);
};
export default Test
so your component Test only renders when you reach that 10px threshold, you could even pass that threshold value as a parameter to your hook, just an idea...

Everytime there is useState, there will be a re-render. In your case you could try useRef to store the value instead of useState, as useRef will not trigger a new render

another idea if you want to stick to your early version is a compromise :have the children of your component memoized, say you pass a children named NestedTest :
import React from 'react'
const NestedTest = () => {
console.log('hit nested')
return (
<div>nested</div>
)
}
export default React.memo(NestedTest)
you will see that the 'hit nested' does not show in the console.
But that might not be what you are expecting in the first place. May be you should try utilizing useRef in your hook instead

Related

window.scrollTo does not work when react modal is opened

import React, { useEffect } from 'react';
import ReactModal from 'react-modal';
const Modal = () => {
useEffect(() => {
window.scrollTo(0, 0);
}, [])
return (
<ReactModal
>
{children}
</ReactModal>
)
}
I want to scroll to top when react modal is opened. For this I put "window.scrollTo(0, 0)" into useEffect. But when react modal is opened it doesn't work. Why doesn't it work properly? Here is an example:
Try alternative solution with ref:
const Modal = () => {
const divRef = useRef(null);
useEffect(() => {
divRef.current.scrollIntoView({ behavior: "auto" }); // or "smooth" behavior
}, []);
return (
<ReactModal>
<div ref={divRef}>{children}</div> // put the divRef to the place/div you want
</ReactModal>
)
}

Add blur effect to navigation scroll bar

I am trying to implement a navbar that has a blur effect when scrolling.
This works, but when I refresh the page, the scrollbar stays in the same position and I don't get any result from window.pageYOffset. The result of this is that I have a transparent navigation bar.
I'm also using TailwindCSS, but I think this doesn't matter.
Code example:
import React, { useState, useEffect } from 'react'
const Navigation: React.FC = () => {
const [top, setTop] = useState(true);
useEffect(() => {
const scrollHandler = () => {
window.pageYOffset > 20 ? setTop(false) : setTop(true)
};
window.addEventListener('scroll', scrollHandler);
return () => {
window.removeEventListener('scroll', scrollHandler);
}
}, [top]);
return (
<header className={`fixed w-full z-30 ${!top && 'bg-white dark:bg-black bg-opacity-80 dark:bg-opacity-80 backdrop-blur dark:backdrop-blur'}`}>
</header>
);
};
export default Navigation
You need to explicitly call scrollHandler() inside the useEffect if you want the navbar to keep its blurred state when the page is refreshed.
useEffect(() => {
const scrollHandler = () => {
setTop(window.pageYOffset <= 20)
};
window.addEventListener('scroll', scrollHandler);
// Explicit call so that the navbar gets blurred when component mounts
scrollHandler();
return () => {
window.removeEventListener('scroll', scrollHandler);
}
}, []);
You can also remove top from the useEffect's dependencies array, you only need it to run when the component is mounted.

How to make react hook persists with ref

I want to toggle an input element by using custom hook.
Here's my custom hook:
import { RefObject, useEffect } from "react";
export const useEscape = (
ref: RefObject<HTMLElement>,
triggerFn: () => void
) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
triggerFn();
}
};
document.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
});
};
and the example that would use the hook
import * as React from "react";
import "./styles.css";
import { useEscape } from "./useEscape";
export default function App() {
const [showInput, setShowInput] = React.useState(false);
const inputRef = React.useRef(null);
useEscape(inputRef, () => {
if (showInput) setShowInput(false);
});
return (
<div>
{showInput && (
<input ref={inputRef} placeholder="click outside to toggle" />
)}
{!showInput && (
<span
style={{ border: "1px solid black" }}
onClick={() => {
console.log("toggle to trigger");
setShowInput(true);
}}
>
click to toggle input
</span>
)}
</div>
);
}
Here's the link to codesandbox demo.
Here's the issue. After I clicked on the span element to toggle into input state. After click outside of the input element, it would never able to toggle back to input state again.
I guess I know why's that happening. The react ref is still pointing to the input element that was created at the first place. Howeve, when react toggle to showing span state, it unmount the input element, and my custom hook never sync with React for the new input element. How can I customize my useEscape hook so the react ref would sync up? (By the way, I want to not use styling as a workaround which visually 'hides' the input element).
import { RefObject, useEffect } from "react";
export const useEscape = (
ref: RefObject<HTMLElement>,
triggerFn: () => void
) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
triggerFn();
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [ref, triggerFn]);
};
Your entire logic is absolutely correct. There is a slight error, instead of
window.removeEventListener, change it to document.removeEventListener.
You are removing event listener on global window object which leads to bug.

Maximum update depth exceeded with useLayoutEffect, useRef

I have the following component:
// component.js
import React from 'react';
import useComponentSize from 'useComponentSize';
const component = () => {
const [comSize, comRef] = useComponentSize();
return (
<div style={{width: '100%'}} ref={comRef}>
<p>hi</p>
</div>
);
};
which is using useComponentSize, a hook I've made:
// useComponentSize.js
import {
useRef,
useState,
useLayoutEffect,
} from 'react';
const useComponentSize = () => {
const [size, setSize] = useState({
width: 0,
height: 0,
});
const resizeRef = useRef();
useLayoutEffect(() => {
setSize(() => ({
width: resizeRef.current.clientWidth,
height: resizeRef.current.clientHeight,
}));
});
return [size, resizeRef];
};
export default useComponentSize;
but for a reason I cannot work out, it always exceeds the maximum update depth. I've tried having useLayoutEffect depend upon resizeRef, which I thought would work, but then it doesn't update again (which upon reflection is exactly how I should have expected a ref to work).
What should I do to make this work properly, and most importantly why does the above cause an infinite loop?
Edit: second attempt using event listeners, still failing. What concept am I missing here?
// component.js
import React, { useRef } from 'react';
import useComponentSize from 'useComponentSize';
const component = () => {
const ref = useRef();
const [comSize] = useComponentSize(ref);
return (
<div style={{width: '100%'}} ref={ref}>
<p>hi</p>
</div>
);
};
import {
useRef,
useState,
useLayoutEffect,
} from 'react';
const useComponentSize = (ref) => {
const [size, setSize] = useState();
useLayoutEffect(() => {
const updateSize = () => {
setSize(ref.current.clientWidth);
}
ref.current.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return [size];
};
export default useComponentSize;
That edit above is based upon this useWindowSize hook, which works great (I'm using it currently as a replacement, although I'd rather still get the above to work, and especially to know why it doesn't work).
A small explanation of what I'm trying to achieve as it wasn't made explicitly clear before: I want the state size to update whenever the size of the referenced component's size changes. That is, if the window resizes, and the component remains the same size, it should not update. But if the component size does change, then the size state should change value to reflect that.
Your code gets stuck in an infinite loop because you haven't passed the dependency array to useEffectLayout hook.
You actually don't need to use useEffectLayout hook at all. You can observe the changes to the DOM element using ResizeObserver API.
P.S: Although OP's problem has already been solved through a demo posted in one of the comments under the question, i am posting an answer for anyone who might look at this question in the future.
Example:
const useComponentSize = (comRef) => {
const [size, setSize] = React.useState({
width: 0,
height: 0
});
React.useEffect(() => {
const sizeObserver = new ResizeObserver((entries, observer) => {
entries.forEach(({ target }) => {
setSize({ width: target.clientWidth, height: target.clientHeight });
});
});
sizeObserver.observe(comRef.current);
return () => sizeObserver.disconnect();
}, [comRef]);
return [size];
};
function App() {
const comRef = React.useRef();
const [comSize] = useComponentSize(comRef);
return (
<div>
<textarea ref={comRef} placeholder="Change my size"></textarea>
<h1>{JSON.stringify(comSize)}</h1>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Can a React portal be used in a Stateless Functional Component (SFC)?

I have used ReactDOM.createPortal inside the render method of a stateful component like so:
class MyComponent extends Component {
...
render() {
return (
<Wrapper>
{ReactDOM.createPortal(<FOO />, 'dom-location')}
</Wrapper>
)
}
}
... but can it also be used by a stateless (functional) component?
Will chime in with an option where you dont want to manually update your index.html and add extra markup, this snippet will dynamically create a div for you, then insert the children.
export const Portal = ({ children, className = 'root-portal', el = 'div' }) => {
const [container] = React.useState(() => {
// This will be executed only on the initial render
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
return document.createElement(el);
});
React.useEffect(() => {
container.classList.add(className)
document.body.appendChild(container)
return () => {
document.body.removeChild(container)
}
}, [])
return ReactDOM.createPortal(children, container)
}
It can be done like this for a fixed component:
const MyComponent = () => ReactDOM.createPortal(<FOO/>, 'dom-location')
or, to make the function more flexible, by passing a component prop:
const MyComponent = ({ component }) => ReactDOM.createPortal(component, 'dom-location')
can it also be used by a stateless (functional) component
?
yes.
const Modal = (props) => {
const modalRoot = document.getElementById('myEle');
return ReactDOM.createPortal(props.children, modalRoot,);
}
Inside render :
render() {
const modal = this.state.showModal ? (
<Modal>
<Hello/>
</Modal>
) : null;
return (
<div>
<div id="myEle">
</div>
</div>
);
}
Working codesandbox#demo
TSX version based on #Samuel's answer (React 17, TS 4.1):
// portal.tsx
import * as React from 'react'
import * as ReactDOM from 'react-dom'
interface IProps {
className? : string
el? : string
children : React.ReactNode
}
/**
* React portal based on https://stackoverflow.com/a/59154364
* #param children Child elements
* #param className CSS classname
* #param el HTML element to create. default: div
*/
const Portal : React.FC<IProps> = ( { children, className, el = 'div' } : IProps ) => {
const [container] = React.useState(document.createElement(el))
if ( className )
container.classList.add(className)
React.useEffect(() => {
document.body.appendChild(container)
return () => {
document.body.removeChild(container)
}
}, [])
return ReactDOM.createPortal(children, container)
}
export default Portal
IMPORTANT useRef/useState to prevent bugs
It's important that you use useState or useRef to store the element you created via document.createElement because otherwise it gets recreated on every re-render
//This div with id of "overlay-portal" needs to be added to your index.html or for next.js _document.tsx
const modalRoot = document.getElementById("overlay-portal")!;
//we use useRef here to only initialize el once and not recreate it on every rerender, which would cause bugs
const el = useRef(document.createElement("div"));
useEffect(() => {
modalRoot.appendChild(el.current);
return () => {
modalRoot.removeChild(el.current);
};
}, []);
return ReactDOM.createPortal(
<div
onClick={onOutSideClick}
ref={overlayRef}
className={classes.overlay}
>
<div ref={imageRowRef} className={classes.fullScreenImageRow}>
{renderImages()}
</div>
<button onClick={onClose} className={classes.closeButton}>
<Image width={25} height={25} src="/app/close-white.svg" />
</button>
</div>,
el.current
);
Yes, according to docs the main requirements are:
The first argument (child) is any renderable React child, such as an element, string, or fragment. The second argument (container) is a DOM element.
In case of stateless component you can pass element via props and render it via portal.
Hope it will helps.
Portal with SSR (NextJS)
If you are trying to use any of the above with SSR (for example NextJS) you may run into difficulty.
The following should get you what you need. This methods allows for passing in an id/selector to use for the portal which can be helpful in some cases, otherwise it creates a default using __ROOT_PORTAL__.
If it can't find the selector then it will create and attach a div.
NOTE: you could also statically add a div and specify a known id in pages/_document.tsx (or .jsx) if again using NextJS. Pass in that id and it will attempt to find and use it.
import { PropsWithChildren, useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
export interface IPortal {
selector?: string;
}
const Portal = (props: PropsWithChildren<IPortal>) => {
props = {
selector: '__ROOT_PORTAL__',
...props
};
const { selector, children } = props;
const ref = useRef<Element>()
const [mounted, setMounted] = useState(false);
const selectorPrefixed = '#' + selector.replace(/^#/, '');
useEffect(() => {
ref.current = document.querySelector(selectorPrefixed);
if (!ref.current) {
const div = document.createElement('div');
div.setAttribute('id', selector);
document.body.appendChild(div);
ref.current = div;
}
setMounted(true);
}, [selector]);
return mounted ? createPortal(children, ref.current) : null;
};
export default Portal;
Usage
The below is a quickie example of using the portal. It does NOT take into account position etc. Just something simple to show you usage. Sky is limit from there :)
import React, { useState, CSSProperties } from 'react';
import Portal from './path/to/portal'; // Path to above
const modalStyle: CSSProperties = {
padding: '3rem',
backgroundColor: '#eee',
margin: '0 auto',
width: 400
};
const Home = () => {
const [visible, setVisible] = useState(false);
return (
<>
<p>Hello World <a href="#" onClick={() => setVisible(true)}>Show Modal</a></p>
<Portal>
{visible ? <div style={modalStyle}>Hello Modal! <a href="#" onClick={() => setVisible(false)}>Close</a></div> : null}
</Portal>
</>
);
};
export default Home;
const X = ({ children }) => ReactDOM.createPortal(children, 'dom-location')
Sharing my solution:
// PortalWrapperModal.js
import React, { useRef, useEffect } from 'react';
import ReactDOM from 'react-dom';
import $ from 'jquery';
const PortalWrapperModal = ({
children,
onHide,
backdrop = 'static',
focus = true,
keyboard = false,
}) => {
const portalRef = useRef(null);
const handleClose = (e) => {
if (e) e.preventDefault();
if (portalRef.current) $(portalRef.current).modal('hide');
};
useEffect(() => {
if (portalRef.current) {
$(portalRef.current).modal({ backdrop, focus, keyboard });
$(portalRef.current).modal('show');
$(portalRef.current).on('hidden.bs.modal', onHide);
}
}, [onHide, backdrop, focus, keyboard]);
return ReactDOM.createPortal(
<>{children(portalRef, handleClose)}</>,
document.getElementById('modal-root')
);
};
export { PortalWrapperModal };

Resources