Can't set scrollTop of HTMLElement in useEffect without timeout function - reactjs

I have a text log component that I want to scroll to the bottom whenever it receives a new log entry, so I have it set the parent's scrollTop to it's scrollHeight in useEffect(). However, this has no effect:
const Log = ({ entries }: LogProps) => {
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logRef && logRef.current) {
let parent = logRef.current.parentNode as HTMLDivElement
parent.scrollTop = parent.scrollHeight;
}
})
return (
<Module className="log" title="Text Log">
<div ref={logRef} className="log__entries">
...
</div>
</Module>
)
}
This code on the other hand, works:
...
useEffect(() => {
if (logRef && logRef.current) {
let parent = logRef.current.parentNode as HTMLDivElement
setTimeout(() => {
parent.scrollTop = parent.scrollHeight;
}, 100)
}
})
...
On my machine, I can set the timeout as low as 39, and it'll work consistently. Lower numbers have sporadic success. Presumably that number will vary by some performance metric, but I have no idea.
Console logging shows that the ref exists, and it does have height enough to scroll by the time useEffect() triggers. Logging before and after parent.scrollTop = parent.scrollHeight; reveals that scrollTop doesn't change.
Am I misunderstanding how useEffect() works, is it because I'm setting the parent's scrollTop, or is there something else I'm missing?

You can achieve it by making a few changes:
useEffect(() => {
logRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [entries])
You can see list of browsers supporting scrollIntoView feature at Mozilla Docs.
If you want more browsers to support then you can install this tiny npm package called smoothscroll-polyfill.

Related

Make a momentary highlight inside a virtualized list when render is not triggered

I have an extensive list of items in an application, so it is rendered using a virtual list provided by react-virtuoso. The content of the list itself changes based on API calls made by a separate component. What I am trying to achieve is whenever a new item is added to the list, the list automatically scrolls to that item and then highlights it for a second.
What I managed to come up with is to have the other component place the id of the newly created item inside a context that the virtual list has access to. So the virtual list looks something like this:
function MyList(props) {
const { collection } = props;
const { getLastId } useApiResultsContext();
cosnt highlightIndex = useRef();
const listRef = useRef(null);
const turnHighlightOff = useCallback(() => {
highlighIndex.current = undefined;
}, []);
useEffect(() => {
const id = getLastId();
// calling this function also resets the lastId inside the context,
// so next time it is called it will return undefined
// unless another item was entered
if (!id) return;
const index = collection.findIndex((item) => item.id === if);
if (index < 0) return;
listRef.current?.scrollToIndex({ index, align: 'start' });
highlightIndex.current = index;
}, [collection, getLastId]);
return (
<Virtuoso
ref={listRef}
data={collection}
itemContent={(index, item) => (
<ItemRow
content={item}
toHighlight={highlighIndex.current}
checkHighlight={turnHighlightOff}
/>
)}
/>
);
}
I'm using useRef instead of useState here because using a state breaks the whole thing - I guess because Virtuouso doesn't actually re-renders when it scrolls. With useRef everything actually works well. Inside ItemRow the highlight is managed like this:
function ItemRow(props) {
const { content, toHighlight, checkHighligh } = props;
const highlightMe = toHighlight;
useEffect(() => {
toHighlight && checkHighlight && checkHighligh();
});
return (
<div className={highlightMe ? 'highligh' : undefined}>
// ... The rest of the render
</div>
);
}
In CSS I defined for the highligh class a 1sec animation with a change in background-color.
Everything so far works exactly as I want it to, except for one issue that I couldn't figure out how to solve: if the list scrolls to a row that was out of frame, the highlight works well because that row gets rendered. However, if the row is already in-frame, react-virtuoso does not need to render it, and so, because I'm using a ref instead of a state, the highlight never gets called into action. As I mentioned above, using useState broke the entire thing so I ended up using useRef, but I don't know how to force a re-render of the needed row when already in view.
I kinda solved this issue. My solution is not the best, and in some rare cases doesn't highlight the row as I want, but it's the best I could come up with unless someone here has a better idea.
The core of the solution is in changing the idea behind the getLastId that is exposed by the context. Before it used to reset the id back to undefined as soon as it is drawn by the component in useEffect. Now, instead, the context exposes two functions - one function to get the id and another to reset it. Basically, it throws the responsibility of resetting it to the component. Behind the scenes, getLastId and resetLastId manipulate a ref object, not a state in order to prevent unnecessary renders. So, now, MyList component looks like this:
function MyList(props) {
const { collection } = props;
const { getLastId, resetLastId } useApiResultsContext();
cosnt highlightIndex = useRef();
const listRef = useRef(null);
const turnHighlightOff = useCallback(() => {
highlighIndex.current = undefined;
}, []);
useEffect(() => {
const id = getLastId();
resetLastId();
if (!id) return;
const index = collection.findIndex((item) => item.id === if);
if (index < 0) return;
listRef.current?.scrollToIndex({ index, align: 'start' });
highlightIndex.current = index;
}, [collection, getLastId]);
return (
<Virtuoso
ref={listRef}
data={collection}
itemContent={(index, item) => (
<ItemRow
content={item}
toHighlight={highlighIndex.current === index || getLastId() === item.id}
checkHighlight={turnHighlightOff}
/>
)}
/>
);
}
Now, setting the highlightIndex inside useEffect takes care of items outside the viewport, and feeding the getLastId call into the properties of each ItemRow takes care of those already in view.

Dots Animation Causing innerHTML Error in React Component

I am trying to create a very basic loading animation inside a react component that displays while my API is loading. I found a solution that does what I want, but once my API is loaded and the component is no longer displaying, I start getting this error:
"Uncaught TypeError: Cannot read properties of null (reading 'innerHTML')"
I have read something about clearing the interval but couldn't figure it out.
Here is the component I built:
const LoadingData = () =>{
var dots = window.setInterval(function() {
var wait = document.getElementById("wait");
if ( wait.innerHTML.length > 5 )
wait.innerHTML = "";
else
wait.innerHTML += ".";
}, 200);
return(
<p>Loading<span id="wait">.</span></p>
)
}
I also get a warning the "dots" is declared, but not used. I am not as concerned about that, but if someone could help me find a better solution I would love to hear it.
Correct way of doing it:
const LoadingData = () => {
// use state to control the dom
const [dots, addDot] = useReducer((state) => (state + 1) % 6, 1);
// set your intervals inside useEffect
useEffect(() => {
const interval = window.setInterval(() => {
addDot();
}, 200);
// remember to clear intervals
return () => clearInterval(interval);
}, [addDot]);
return (
<p>
Loading<span id="wait">{".".repeat(dots)}</span>
</p>
);
};
live example

React Infinite Loading hook, previous trigger

Im trying to make a hook similar to Waypoint.
I simply want to load items and then when the waypoint is out of screen, allow it to load more items if the waypoint is reached.
I can't seem to figure out the logic to have this work properly.
Currently it see the observer state that its on the screen. then it fetches data rapidly.
I think this is because the hook starts at false everytime. Im not sure how to make it true so the data can load. Followed by the opposite when its reached again.
Any ideas.
Here's the hook:
import { useEffect, useState, useRef, RefObject } from 'react';
export default function useOnScreen(ref: RefObject<HTMLElement>) {
const observerRef = useRef<IntersectionObserver | null>(null);
const [isOnScreen, setIsOnScreen] = useState(false);
useEffect(() => {
observerRef.current = new IntersectionObserver(([entry]) => {
if (isOnScreen !== entry.isIntersecting) {
setIsOnScreen(entry.isIntersecting);
}
});
}, []);
useEffect(() => {
observerRef.current.observe(ref.current);
return () => {
observerRef.current.disconnect();
};
}, [ref]);
return isOnScreen;
}
Here's the use of it:
import React, { useRef } from 'react';
import { WithT } from 'i18next';
import useOnScreen from 'utils/useOnScreen';
interface IInboxListProps extends WithT {
messages: any;
fetchData: () => void;
searchTerm: string;
chatID: string | null;
}
const InboxList: React.FC<IInboxListProps> = ({ messages, fetchData, searchTerm, chatID}) => {
const elementRef = useRef(null);
const isOnScreen = useOnScreen(elementRef);
if (isOnScreen) {
fetchData();
}
const renderItem = () => {
return (
<div className='item unread' key={chatID}>
Item
</div>
);
};
const renderMsgList = ({ messages }) => {
return (
<>
{messages.map(() => {
return renderItem();
})}
</>
);
};
let messagesCopy = [...messages];
//filter results
if (searchTerm !== '') {
messagesCopy = messages.filter(msg => msg.user.toLocaleLowerCase().startsWith(searchTerm.toLocaleLowerCase()));
}
return (
<div className='conversations'>
{renderMsgList({ messages: messagesCopy })}
<div className='item' ref={elementRef} style={{ bottom: '10%', position: 'relative',backgroundColor:"blue",width:"5px",height:"5px" }} />
</div>
);
};
export default InboxList;
Let's inspect this piece of code
const [isOnScreen, setIsOnScreen] = useState(false);
useEffect(() => {
observerRef.current = new IntersectionObserver(([entry]) => {
if (isOnScreen !== entry.isIntersecting) {
setIsOnScreen(entry.isIntersecting);
}
});
}, []);
We have the following meanings:
.isIntersecting is TRUE --> The element became visible
.isIntersecting is FALSE --> The element disappeared
and
isOnScreen is TRUE --> The element was at least once visible
isOnScreen is FALSE--> The element was never visible
When using a xor (!==) you specify that it:
Was never visible and just became visible
this happens 1 time just after the first intersection
Was visible once and now disappeared
this happens n times each time the element is out of the screen
What you want to do is to get more items each time the element intersects
export default function useOnScreen(ref: RefObject<HTMLElement>, onIntersect: function) {
const observerRef = useRef<IntersectionObserver | null>(null);
const [isOnScreen, setIsOnScreen] = useState(false);
useEffect(() => {
observerRef.current = new IntersectionObserver(([entry]) => {
setIsOnScreen(entry.isIntersecting);
});
}, []);
useEffect(()=?{
if(isOnScreen){
onIntersect();
}
},[isOnScreen,onIntersect])
...
}
and then use it like:
const refetch= useCallback(()=>{
fetchData();
},[fetchData]);
const isOnScreen = useOnScreen(elementRef, refetch);
or simply:
const isOnScreen = useOnScreen(elementRef, fetchData);
If fetchData changes reference for some reason, you might want to use the following instead:
const refetch= useRef(fetchData);
const isOnScreen = useOnScreen(elementRef, refetch);
Remember that useOnScreen has to call it like onIntersect.current()
In InboxList component, what we are saying by this code
if (isOnScreen) {
fetchData();
}
is that, every time InboxList renders, if waypoint is on screen, then initiate the fetch, regardless of whether previous fetch is still in progress.
Note that InboxList could get re-rendered, possibly multiple times, while the fetch is going on, due to many reasons e.g. parent component re-rendering. Every re-rendering will initiate new fetch as long as waypoint is on screen.
To prevent this, we need to keep track of ongoing fetch, something like typical isLoading state variable. Then initiate new fetch only if isLoading === false && isOnScreen.
Alternatively, if it is guaranteed that every fetch will push the waypoint off screen, then we can initiate the fetch only when waypoint is coming on screen, i.e. isOnScreen is changing to true from false :
useEffect(() => {
if (isOnScreen) {
fetchData();
}
}, [isOnScreen]);
However, this will not function correctly if our assumption, that the waypoint goes out of screen on every fetch, does not hold good. This could happen because
pageSize of fetch small and display area can accommodate more
elements
data received from a fetch is getting filtered out due to
client side filtering e.g. searchTerm.
As my assumption. Also you can try this way.
const observeRef = useRef(null);
const [isOnScreen, setIsOnScreen] = useState(false);
const [prevY, setPrevY] = useState(0);
useEffect(()=>{
fetchData();
var option = {
root : null,
rootmargin : "0px",
threshold : 1.0 };
const observer = new IntersectionObserver(
handleObserver(),
option
);
const handleObserver = (entities, observer) => {
const y = observeRef.current.boundingClientRect.y;
if (prevY > y) {
fetchData();
}
setPrevY(y);
}
},[prevY]);
In this case we not focus chat message. we only focus below the chat<div className="item element. when div element trigger by scroll bar the fetchData() calling again and again..
Explain :
In this case we need to use IntersectionObserver for read the element position. we need to pass two parameter for IntersectionObserver.
-first off all in the hanlderObserver you can see boundingClientRect.y. the boundingClientRect method read the element postion. In this case we need only y axis because use y.
when the scrollbar reach div element, y value changed. and then fetchData() is trigger again.
root : This is the root to use for the intersection. rootMargin : Just like a margin property, which is used to provide the margin value to the root either in pixel or in percent (%) . threshold : The number which is used to trigger the callback once the intersection’s area changes to be greater than or equal to the value we have provided in this example .
finally you can add loading status for loading data.
return (
<div className='conversations'>
{renderMsgList({ messages: messagesCopy })}
<div className='item' ref={observeRef} style={{ bottom: '10%', position: 'relative',backgroundColor:"blue",width:"5px",height:"5px" }} />
</div>
);
};
I hope its correct, i'm not sure. may it's helpful someone. thank you..

React fader component fade direction not changing

I'm trying to create a small component that will fade between it's children when one of its methods is called. I've been following this code, but so it supports any number of children. So far, I have this:
export const Fader = React.forwardRef<FaderProps, Props>((props, ref) => {
const children = React.Children.toArray(props.children);
const [currentChild, setCurrentChild] = useState({
child: props.startIndex || 0,
direction: 1,
});
let nextChild = 0;
const fadeNext = (): void => {
queueNextFade(); //Queues the next fade which fades in the next child after the current child has faded out
setCurrentChild({
child: currentChild.child,
direction: +!currentChild.direction,
});
nextChild = currentChild.child + 1;
}
const fadePrev = (): void => {
}
const fadeTo = (index: number): void => {
}
const queueNextFade = (): void => {
setTimeout(() => {
setCurrentChild({
child: nextChild,
direction: +!currentChild.direction,
});
}, props.fadeTime || 500)
}
useImperativeHandle(ref, () => ({ fadeNext, fadePrev, fadeTo }));
return (
<div>
{
React.Children.map(children, (child, i) => (
<div key={i}
style={{
opacity: i === currentChild.child ? currentChild.direction : "0",
transition: `opacity ${props.fadeTime || 500}ms ease-in`,
}}
>
{child}
</div>
))
}
</div>
)
});
Logic wise it does work, but what actually happens is that the first child fades out, but the next child doesn't fade in. If faded again, the second child fades in then fades out, and the next child fades in.
(View in sandbox)
For a while I was confused as to why that was happening because I'm using the same logic as the other library. I did some research on seeing if I could make useState be instant and I came across this post and I quote from it:
Even if you add a setTimeout the function, though the timeout will run after some time by which the re-render would have happened, the setTimeout will still use the value from its previous closure and not the updated one.
which I realised is what's happening in my situation. I start the setTimeout in which currentChild.direction is 1. Then a state change happens and the direction changes to 0. A long time later the setTimeout finishes but instead of changing the direction from 0 to 1, it changes from 1 to 0 because it kept it original value from when it was first called, hence why the second child doesn't fade in and just stays invisible.
I could just change it to:
let currentChild = {...}
and the have a "blank" useState to act as a forceUpdate but I know force updates are against React's nature and there's probably a better way to do this anyway.
If anyone could help out, I would appreaciate it
For anyone in the future, I found this post explaning that setTimeout will use the value from the initial render of the component
To fix this, I changed my setTimeout to:
setTimeout(() => {
setCurrentChild(currentChild => ({
child: nextChild,
direction: +!currentChild.direction,
}));
}, props.fadeTime || 500)

React useCallback does not get updated on state change

The sample below is a simplified excerpt where a child component emits events based on mouse behaviours. React then should update the DOM according to the emitted events.
function SimpleSample() {
const [addons, setAddons] = React.useState<any>({
google: new GoogleMapsTile('google'),
})
const [tooltip, setTooltip] = React.useState<null | { text: string[]; x; y }>(null)
React.useEffect(() => {
// ...
}, [])
const mapEventHandle = React.useCallback(
(event: MapEvents) => {
console.log('event', event.type, tooltip) // LOG 1
if (event.type === MapEventType.mouseoverPopup_show) {
setTooltip({ text: event.text, x: event.coordinates[0], y: event.coordinates[1] })
} else if (event.type === MapEventType.mouseoverPopup_move) {
if (tooltip) setTooltip({ ...tooltip, x: event.coordinates[0], y: event.coordinates[1] })
} else if (event.type === MapEventType.mouseoverPopup_hide) {
setTooltip(null)
}
},
[tooltip]
)
console.log('render', tooltip) // LOG 2
return <MapComponent addons={addons} onEvent={mapEventHandle} />
}
The following order of events is expected:
mouseoverPopup_show is emitted, then tooltip changed from null to a value, a rerender occurs
mouseoverPopup_move is emitted, then tooltip is updated, triggering a rerender
What actually is happening:
Logpoint LOG 2 logs the updated value of tooltip (correct)
When mapEventHandle is called again, the value of tooltip inside that closure (logpoint LOG 1) is never updated, being always null.
Am I missing somethinig? Using the wrong hook?
Here's a codesandbox for it
https://codesandbox.io/s/blissful-torvalds-wm27f
EDIT: On de codesandbox sample setTooltip is not even triggering a rerender
Thanks for the help folks, the issue seems to be down inside a dependency of <MapComponent/>. It ended up saving a reference to the old callback on construction. Still a caveat to watch for, and which i probably wouldnt face with class components...
//something like this
class MapComponent {
emitter = this.props.onChange //BAD
emitter = (...i) => this.props.onChange(...i) //mmkay
}
I think event.coordinates is undefined so event.coordinates[0] causes an error.
If you do: setTooltip({working:'fine'}); you'll get type errors but it does set the toolTip state and re renders.
Thanks to your answer it helped me debug mine which was a bit different. Mine was not working because the callback reference was kept in a state value of the child component.
const onElementAdd = useCallBack(...)..
<Dropzone onElementAdded={props.onElementAdded} />
export const Dropzone = (props: DropzoneProps): JSX.Element => {
const [{ isOver }, drop] = useDrop(
() => ({
accept: props.acceptedTypes,
drop: (item: BuilderWidget, monitor): void => {
if (monitor.didDrop()) return;
props.onElementAdded(item);
},
}),
// missed props.onElementAdded here
[props.onElementAdded, props.acceptedTypes, props.disabled],
);

Resources