Why doesn't useRef trigger a child rerender in this situation? - reactjs

I'm running into an issue where my hobbies list is not being displayed if it was previously undefined.
The goal is to cycle through a list of hobbies and display them in the UI. If one hobby is passed, only one should be displayed. If none are passed, nothing should be displayed.
Basically, setHobbies(["Cycling"]) followed by setHobbies(undefined) correctly turns off the hobbies render, but then the next setHobbies(["Reading"]) doesn't show up.
Using a debugger I've been able to verify that the relevant code in the useEffect hook in EmployeeInfoWrapper does get triggered, and hobbiesRef.current set accordingly, but it doesn't cause EmployeeInfo to rerender.
Container:
const Container = () => {
const [hobbies, setHobbies] = React.useState<string[]>();
setLabels(["Board games"]); // example of how it's set; in reality this hook is passed down and set in lower components
return (
<SpinnerWrapper hobbies=hobbies otherInfo=otherInfo />
);
};
EmployeeInfoWrapper:
export const EmployeeInfoWrapper = (props) => {
const { hobbies, otherInfo } = props;
const [indx, setIndx] = React.useState<number>(0);
// this doesn't work due to https://github.com/facebook/react/issues/14490, shouldn't matter though because it's handled in the useEffect
const hobbyRef = React.useRef(hobbies?.length ? hobbies[indx] : "");
useEffect(() => {
if (hobbies?.length > 1) { // doubt this is relevant as my bug is with 0 or 1-length hobbies prop
hobbyRef.current = hobbies[indx];
setTimeout(() => setIndx((indx + 1) % hobbies.length), 2000);
}
if (hobbies?.length == 1) {
hobbyRef.current = hobbies[indx]; // can see with debugger this line is hit
}
if (!hobbies?.length) { // covers undefined or empty cases
hobbyRef.current = "";
}
}, [hobbies]);
return (
<EmployeeInfo hobby={hobbyRef.current} otherInfo={otherInfo} />
);
};

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.

Hooks (useLayoutEffekt) gets triggered too often / values inside Component

I am having problems figuring out the correct way how to use useEffect / useLayoutEffect.
I have a component, which waits on a pointerevent (PointerEventTypes.POINTERTAP (comes from babylon). It is within a useLayoutEffect hook. It then fires up pointerClick() to do something.
The problem I am having is that there are 2 dependencies I need to watch: buildings and activeBuilding. How can I achieve that the function pointerClick() does not get triggered two times on the first single click?
I think what is happening is that there is one event (PointerTAP) and the function gets called. Then within the function I am changing activeBuilding which then again triggers a re-render and a second run of the function pointerClick().
If I instead use useEffect (not useLayoutEffect) I have to click twice to set the data because it does change the values after rendering. So it's not what I want.
Here is the example code:
const pointerClick = () => {
if (scene) {
const pickInfo = scene.pick(scene.pointerX, scene.pointerY)
if (buildings.length === 0 && activeBuilding === -1) {
setActiveBuilding(0)
}
if (activeBuilding != -1) {
if (pickInfo?.pickedMesh?.metadata?.measurement?.type === 'handle') {
handleDeletePoint(
pickInfo.pickedMesh.metadata.measurement.id,
activeBuilding
)
} else if (pickInfo?.pickedMesh?.name === 'measureOutline') {
setActiveBuilding(-1)
}
else if (
!pickInfo?.pickedMesh?.metadata?.measurement &&
pickInfo?.pickedPoint
) {
handleAddPoint(pickInfo.pickedPoint, activeBuilding)
}
}
}
}
useLayoutEffect(() => {
if (scene) {
const obs = scene?.onPointerObservable.add((pointerInfoEvent) => {
switch (pointerInfoEvent.type) {
case PointerEventTypes.POINTERTAP:
pointerClick()
// setClicked(false)
break
}
})
return () => {
scene.onPointerObservable.remove(obs)
}
}
}, [canvas, scene, buildings, activeBuilding])
return <Measure />
}
Do I have to get rid of one of the two: buildings or activeBuilding as a dependency (what I think I cannot do since I need both values to be changeable). What am I doing wrong here?
Thank you so much!

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..

Can I use ReactElement as useState argument?

I'm new in React and I wonder is using ReactElement as useState argument normal?
I try to do it and everything works fine. Is it anti-pattern or it's OK?
Unfortunately, I didn't find any information about it in documentation
const [infoBox, setInfobox] = useState<ReactElement|null>(null);
const catalogLoadedDataEmpty = useSelector(getCatalogLoadedDataEmptySelector);
const catalogHasErrors = useSelector(getCatalogHasErrorsSelector);
...
useEffect(() => {
let infoBoxTitle;
if (catalogLoadedDataEmpty) {
infoBoxTitle = t('pages.Brands.errors.noResults.title');
} else if (catalogHasErrors) {
infoBoxTitle = errorsByErrorCode[EErrorCodes.UNRECOGNIZED_ERROR](t);
} else {
setInfobox(null);
return;
}
setInfobox(<InfoBox
className={catalogInfoBoxClassname}
iconName={EInfoBoxIcon.error}
title={infoBoxTitle}
description={noResultsDescription}
/>);
}, [catalogLoadedDataEmpty, catalogHasErrors]);
You can, but it's easy to create bugs where you expect the page to update, but it doesn't, because you forgot to update the state. It's usually better to save data in state, and then use that data to render fresh elements on each render.
And in your case i'd go one step further: this shouldn't be a state variable at all. The values catalogLoadedDataEmpty and catalogHasErrors are enough to determine the desired output directly. You can thus remove the use effect, and in so doing get rid of the double-render that you currently have:
const catalogLoadedDataEmpty = useSelector(getCatalogLoadedDataEmptySelector);
const catalogHasErrors = useSelector(getCatalogHasErrorsSelector);
let infoBoxTitle;
if (catalogLoadedDataEmpty) {
infoBoxTitle = t('pages.Brands.errors.noResults.title');
} else if (catalogHasErrors) {
infoBoxTitle = errorsByErrorCode[EErrorCodes.UNRECOGNIZED_ERROR](t);
}
const infoBox = infoBoxTitle ? (
<InfoBox
className={catalogInfoBoxClassname}
iconName={EInfoBoxIcon.error}
title={infoBoxTitle}
description={noResultsDescription}
/>
) : null

React.js - Using one component multiple times with different settings

I'm trying to create a re-usable component that wraps similar functionality where the only things that change are a Title string and the data that is used to populate a Kendo ComboBox.
So, I have a navigation menu that loads (at the moment) six different filters:
{
context && context.Filters && context.Filters.map((item) => getComponent(item))
}
GetComponent gets the ID of the filter, gets the definition of the filter from the context, and creates a drop down component passing in properties:
function getComponent(item) {
var filterDefinition = context.Filters.find(filter => filter.Id === item.Id);
switch (item.DisplayType) {
case 'ComboBox':
return <DropDownFilterable key={item.Id} Id={item.Id} Definition={filterDefinition} />
default:
return null;
}
}
The DropDownFilterable component calls a service to get the data for the combo box and then loads everything up:
const DropDownFilterable = (props) => {
const appService = Service();
filterDefinition = props.Definition;
console.log(filterDefinition.Name + " - " + filterDefinition.Id);
React.useEffect(() => {
console.log("useEffect: " + filterDefinition.Name + " - " + filterDefinition.Id);
appService.getFilterValues(filterDefinition.Id).then(response => {
filterData = response;
})
}, []);
return (
<div>
<div className="row" title={filterDefinition.DisplayName}>{filterDefinition.DisplayName}</div>
<ComboBox
id={"filterComboBox_" + filterDefinition.Id}
data={filterData}
//onOpen={console.log("test")}
style={{zIndex: 999999}}
dataItemKey={filterDefinition && filterDefinition.Definition && filterDefinition.Definition.DataHeaders[0]}
textField={filterDefinition && filterDefinition.Definition && filterDefinition.Definition.DataHeaders[1]}
/>
</div>
)
}
Service call:
function getFilterValues(id) {
switch(id) {
case "E903B2D2-55DE-4FA3-986A-8A038751C5CD":
return fetch(Settings.url_getCurrencies).then(toJson);
default:
return fetch(Settings.url_getRevenueScenarios).then(toJson);
}
};
What's happening is, the title (DisplayName) for each filter is correctly rendered onto the navigation menu, but the data for all six filters is the data for whichever filter is passed in last. I'm new to React and I'm not 100% comfortable with the hooks yet, so I'm probably doing something in the wrong order or not doing something in the right hook. I've created a slimmed-down version of the app:
https://codesandbox.io/s/spotlight-react-full-forked-r25ns
Any help would be appreciated. Thanks!
It's because you are using filterData incorrectly - you defined it outside of the DropDownFilterable component which means it will be shared. Instead, set the value in component state (I've shortened the code to include just my changes):
const DropDownFilterable = (props) => {
// ...
// store filterData in component state
const [filterData, setFilterData] = React.useState(null);
React.useEffect(() => {
// ...
appService.getFilterValues(filterDefinition.Id).then(response => {
// update filterData with response from appService
setFilterData(response);
})
}, []);
// only show a ComboBox if filterData is defined
return filterData && (
// ...
)
}
Alternatively you can use an empty array as the default state...
const [filterData, setFilterData] = React.useState([]);
...since the ComboBox component accepts an array for the data prop. That way you won't have to conditionally render.
Update
For filterDefinition you also need to make sure it is set properly:
const [filterDefinition, setFilterDefinition] = React.useState(props.Definition);
// ...
React.useEffect(() => {
setFilterDefinition(props.Definition);
}, [props.Definition]);
It may also be easier to keep filterDefinition out of state if you don't expect it to change:
const filterDefinition = props.Definition || {};

Resources