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

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.

Related

Two independent state piece causing infinite loop - useEffect

I can't wrap my head around the problem I'm experiencing Basically I submit the form and it checks whether or not there are empty values. I then paint the input border either red or green. However, I need to repaint the border all the time, meaning that if user enters a value, the border should turn green (hence the useEffect). I have 2 pieces of state here. One keeps track of validation error indexes (for value === '') The other piece is the createForm state (form fields) itself.
I then send down the indexes via props.
NOTE: infinite loop occurs not on initial render, but when form is submitted with empty values. The infinite loop DOES NOT occur if there is no empty field on form submit.
I'm willing to share additional info on demand.
const [createForm, setCreateForm] = React.useState(() => createFormFields);
const [validationErrorIndexes, setValidationErrorIndexes] = React.useState([]);
//Function that is being triggered in useEffect - to recalculate validaiton error indexes and resets the indexes.
const validateFormFields = () => {
const newIndexes = [];
createForm.forEach((field, i) => {
if (!field.value) {
newIndexes.push(i);
}
})
setValidationErrorIndexes(newIndexes);
}
//(infinite loop occurs here).
React.useEffect(() => {
if (validationErrorIndexes.length) {
validateFormFields();
return;
}
}, [Object.values(createForm)]);
//Function form submit.
const handleCreateSubmit = (e) => {
e.preventDefault();
if (createForm.every(formField => Boolean(formField.value))) {
console.log(createForm)
// TODO: dispatch -> POST/createUser...
} else {
validateFormFields();
}
}
//I then pass down validationErrorIndexes via props and add error and success classes conditionally to paint the border.
{createForm && createForm.length && createForm.map((formEl, i) => {
if (formEl.type === 'select') {
return (
<Select
className={`create-select ${(validationErrorIndexes.length && validationErrorIndexes.includes(i)) && 'error'}`}
styles={customStyles}
placeholder={formEl.label}
key={i}
value={formEl.value}
onChange={(selectedOption) => handleOptionChange(selectedOption, i)}
options={formEl.options}
/>
)
}
return (
<CustomInput key={i} {...{ label: formEl.label, type: formEl.type, value: formEl.value, formState: createForm, formStateSetter: setCreateForm, i, validationErrorIndexes }} />
)
})}
Ok, so here is what is happening:
Initial render - validationErrorIndexes is empty, bugged useEffect does not hit if and passes.
You click submit with 1 empty field - submit calls validateFormFields, it calculates, setValidationErrorIndexes is set, now its length is non zero and if in bugged useEffect will be hit. And here we got a problem...
The problem: on each rerender of your component Object.values(createForm) which are in your dependency array are evaluated and returning new [array]. Every single rerender, not the old one with same data, the new one with same data. And asuming the if guard is gone now due to length is non 0 - validateFormFields is called again. Which does its evaluations, and setting new data with setValidationErrorIndexes. Which causes rerendering. And Object.values(createForm) returns a new array again. So welp.
So basically, 2 solutions.
One is obvious and just replace [Object.values(createForm)] with just [createForm]. (why do you need Object.values here btw?)
Second one - well if it a must to have [Object.values(createForm)].
const validateFormFields = () => {
const newIndexes = [];
console.log(createForm);
createForm.forEach((field, i) => {
if (!field.value) {
newIndexes.push(i);
}
});
console.log(newIndexes);
setValidationErrorIndexes((oldIndexes) => {
// sorry, just an idea. Compare old and new one and return old if equals.
if (JSON.stringify(oldIndexes) === JSON.stringify(newIndexes)) {
return oldIndexes; // will not cause rerender.
}
return newIndexes;
});
};

Do I have a stale closure in my React app using react-dnd for drag and drop?

I am building a drag-n-drop application that lets users build up a tree of items that they've dragged onto a list.
When dragging items onto a list, there are two possible actions:
The item is dropped onto the list and added
The item is dropped onto an existing item and is added as a child of that item
There is a bug when adding child items. I have reproduced the bug in CodeSandbox here:
https://codesandbox.io/s/react-dnd-possible-stale-closure-2mpjju?file=/src/index.tsx**
Steps to reproduce the bug:
Open the CodeSandbox link
Drag 3 or more items onto the list
Drag an item and drop it onto the first or second item
You will see it successfully adds the item as a child, but the root items below it get removed
I will explain what I think is happening below, but here's an overview of the code:
A list of draggable items:
export function DraggableItems() {
const componentTypes = ["Car", "Truck", "Boat"];
return (
<div>
<h4>These can be dragged:</h4>
{componentTypes.map((x) => (
<DraggableItem itemType={x} />
))}
</div>
);
}
function DraggableItem({ itemType }: DraggableItemProps) {
const [, drag] = useDrag(() => ({
type: "Item",
item: { itemType: itemType }
}));
return <div ref={drag}>{itemType}</div>;
}
...these items can be dropped on two places: the DropPane and the previously DroppedItems.
DropPane:
export function DropPane() {
const [items, setItems] = useState<Array<Item>>([]);
const [, drop] = useDrop(
() => ({
accept: "Item",
drop: (droppedObj: any, monitor: any) => {
if (monitor.didDrop()) {
console.log("Drop already processed by nested dropzone");
} else {
const newItem = makeRandomItem(droppedObj.itemType);
setItems([...items, newItem]);
}
}
}),
[items]
);
const deleteItem = (idToDelete: number) => {
// a recursive function which filters out the item with idToDelete
const deleteItemRecursively = (list: Array<Item>) => {
const filteredList = list.filter((x) => x.id !== idToDelete);
if (filteredList.length < list.length) {
return filteredList;
} else {
return list.map((x) => {
x.children = deleteItemRecursively(x.children);
return x;
});
}
};
// the recursive function is called initially with the items state object
const listWithTargetDeleted = deleteItemRecursively(items);
setItems(listWithTargetDeleted);
};
const addItemAsChild = (child: Item, targetParent: Item) => {
// same as the delete function, this recursive function finds the correct
// parent and adds the child item to its list of children
const addItemAsChildRecursively = (list: Array<Item>) => {
return list.map((x) => {
if (x.id === targetParent.id) {
x.children.push(child);
return x;
} else {
x.children = addItemAsChildRecursively(x.children);
return x;
}
});
};
// it's called initially with the items state object
const reportComponentsWithChildAdded = addItemAsChildRecursively(items);
setItems(reportComponentsWithChildAdded);
};
return (
<div ref={drop} style={{ border: "1px solid black", marginTop: "2em" }}>
<h4>You can any items anywhere here to add them to the list:</h4>
{items.length === 0 || (
<DroppedItemList
items={items}
onDeleteClicked={deleteItem}
addItemAsChild={addItemAsChild}
indentation={0}
/>
)}
</div>
);
}
It's the addItemAsChild function that I believe may be causing the error, but I am not sure since if there is a stale closure - i.e. the items list is getting wrapped and passed into the DroppedItemList to be called - I would think it would happen for the deleteItem function, but that method works fine.
To elaborate, if I add 5 items to the list, then add a breakpoint in addItemsAsChild and drop an item on the #1 in the list (to add it as a child), the items state object only has one item in it (it should have 5 since there are 5 items on screen). If I drop an item onto the 2nd item in the list, the item state object has 2 items in it instead of 5... and so on. It seems that the item state gets closed within addItemsAsChild when that item is rendered, but this is only happening for addItemsAsChild and not for the delete?
I cannot figure out why this is happening and several fixes have failed. Can anyone help? Alternative approaches are welcome if you think I'm doing something wrong.
Just figured this out after many wasted hours. react-dnd really need to improve their documentation as this is not an adequate explanation of what the useDrop() hook needs:
depsA dependency array used for memoization. This behaves like the built-in useMemoReact hook. The default value is an empty array for function spec, and an array containing the spec for an object spec.
The translation is that any state objects that will be modified by any callback within useDrop needs to be referenced in the dependency array.
In my DropPane I have a list of components and they appear in the dep array for the useDrop at that level, but in the DroppedItem I have another useDrop.
The solution is the prop-drill the items array all the way down to the DroppedItem component and add the items array as a dependency. I will update the CodeSandbox just for future reference.

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

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} />
);
};

React native: useState not updating correctly

I'm new to react native and currently struggling with an infinite scroll listview. It's a calendar list that need to change depending on the selected company (given as prop). The thing is: the prop (and also the myCompany state are changed, but in the _loadMoreAsync method both prop.company as well as myCompany do hold their initial value.
import * as React from 'react';
import { FlatList } from 'react-native';
import * as Api from '../api/api';
import InfiniteScrollView from 'react-native-infinite-scroll-view';
function CalenderFlatList(props: { company: any }) {
const [myCompany, setMyCompany] = React.useState(null);
const [data, setData] = React.useState([]);
const [canLoadMore, setCanLoadMore] = React.useState(true);
const [startDate, setStartDate] = React.useState(undefined);
let loading = false;
React.useEffect(() => {
setMyCompany(props.company);
}, [props.company]);
React.useEffect(() => {
console.log('set myCompany to ' + (myCompany ? myCompany.name : 'undefined'));
_loadMoreAsync();
}, [myCompany]);
async function _loadMoreAsync() {
if ( loading )
return;
loading = true;
if ( myCompany == null ) {
console.log('no company selected!');
return;
} else {
console.log('use company: ' + myCompany.name);
}
Api.fetchCalendar(myCompany, startDate).then((result: any) => {
// code is a little more complex here to keep the already fetched entries in the list...
setData(result);
// to above code also calculates the last day +1 for the next call
setStartDate(lastDayPlusOne);
loading = false;
});
}
const renderItem = ({ item }) => {
// code to render the item
}
return (
<FlatList
data={data}
renderScrollComponent={props => <InfiniteScrollView {...props} />}
renderItem={renderItem}
keyExtractor={(item: any) => '' + item.uid }
canLoadMore={canLoadMore}
onLoadMoreAsync={() => _loadMoreAsync() }
/>
);
}
What I don't understand here is why myCompany is not updating at all in _loadMoreAsync while startDate updates correctly and loads exactly the next entries for the calendar.
After the prop company changes, I'd expect the following output:
set myCompany to companyName
use company companyName
But instead i get:
set myCompany to companyName
no company selected!
I tried to reduce the code a bit to strip it down to the most important parts. Any suggestions on this?
Google for useEffect stale closure.
When the function is called from useEffect, it is called from a stale context - this is apparently a javascript feature :) So basically the behavior you are experiencing is expected and you need to find a way to work around it.
One way to go may be to add a (optional) parameter to _loadMoreAsync that you pass from useEffect. If this parameter is undefined (which it will be when called from other places), then use the value from state.
Try
<FlatList
data={data}
renderScrollComponent={props => <InfiniteScrollView {...props} />}
renderItem={renderItem}
keyExtractor={(item: any) => '' + item.uid }
canLoadMore={canLoadMore}
onLoadMoreAsync={() => _loadMoreAsync() }
extraData={myCompany}
/>
If your FlatList depends on a state variable, you need to pass that variable in to the extraData prop to trigger a re-rendering of your list. More info here
After sleeping two nights over the problem I solved it by myself. The cause was an influence of another piece of code that used React.useCallback(). And since "useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed" (https://reactjs.org/docs/hooks-reference.html#usecallback) the code worked with the old (or initial) state of the variables.
After creating the whole page new from scratch I found this is the reason for that behavior.

When are components defined in functions evaluated? (React Hooks)

Suppose I have a component that renders a list item:
const ListItem = ({ itemName }) => {
return (
<div>
{itemName}
</div>
)
}
And because this list item is used in many places in my app, I define a custom hook to render the list and control the behavior of each list instance:
const useListItems = () => {
const [ showList, setShowList ] = useState(true)
const { listItemArray, isLoaded } = useListContext() // Context makes api call
const toggleShowList = setShowList(!showList)
function renderListItems() {
return isLoaded && !!listItemArray ? listItemArray.map((itemName, index) => (
<ListItem key={index} isVisible={showList} itemName={itemName}/>
))
:
null
}
// Some other components and logic...
return {
// ...Other components and logic,
renderListItems,
toggleShowList,
}
}
My first question is, when will the array of ListItems actually be evaluated ? Will the jsx resulting from renderListItems() be calculated every time renderListItems() is called? Or would it happen every time useListItems() is called?
Second question: if I call useListItems() in another component but don't call renderListItems(), does that impact whether the components are evaluated?
I have been struggling to find an answer to this, so thanks in advance.

Resources