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

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.

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.

React component not re-rendering on component state change

I am working on a sidebar using a recursive function to populate a nested list of navigation items.
Functionally, everything works except for the re-render when I click on one of the list items to toggle the visibility of the child list.
Now, when I expand or collapse the sidebar (the parent component with its visibility managed in its own state), the list items then re-render as they should. This shows me the state is being updated.
I have a feeling this possibly has something to do with the recursive function?
import React, { useState } from "react";
import styles from "./SidebarList.module.css";
function SidebarList(props) {
const { data } = props;
const [visible, setVisible] = useState([]);
const toggleVisibility = (e) => {
let value = e.target.innerHTML;
if (visible.includes(value)) {
setVisible((prev) => {
let index = prev.indexOf(value);
let newArray = prev;
newArray.splice(index, 1);
return newArray;
});
} else {
setVisible((prev) => {
let newArray = prev;
newArray.push(value);
return newArray;
});
}
};
const hasChildren = (item) => {
return Array.isArray(item.techniques) && item.techniques.length > 0;
};
const populateList = (data) => {
return data.map((object) => {
return (
<>
<li
key={object.name}
onClick={(e) => toggleVisibility(e)}
>
{object.name}
</li>
{visible.includes(object.name) ? (
<ul id={object.name}>
{hasChildren(object) && populateList(object.techniques)}
</ul>
) : null}
</>
);
});
};
let list = populateList(data);
return <ul>{list}</ul>;
}
export default SidebarList;
There are many anti patterns with this code but I will just focus on rendering issue. Arrays hold order. Your state does not need to be ordered so it's easier to modify it, for the case of demo I will use object. Your toggle method gets event, but you want to get DOM value. That's not necessary, you could just sent your's data unique key.
See this demo as it fixes the issues I mentioned above.

ReactJS sort by Asc/Desc not rendering sorted list from nested component

Project Overview
I am currently learning ReactJS and am creating a Pokedex app that allows the user to sort Pokemon objects by specific properties (name, type, hp, etc). The end product should have a search input to filter by pokemon name, a drop-down selector for sort criteria, and buttons for sort Ascend and sort Descend. State being tracked is searchQuery, sortBy, sortSelected, and pokeData(my data file).
Full code can be viewed here: https://github.com/julianne-vela/Pokedex-React/tree/dev
File Structure: SearchPage.js > SideBar.js > SortMenu.js
Problem
onClick button in SortMenu is not rendering sorted Pokemon list.
Expected Result: Click Asc/Desc button in SortMenu -> Pokemon list renders in sorted order based on criteria selected in drop-down and Asc/Desc button clicked.
Actual Result: Drop-down is updating state with correct sort criteria selected but button is not triggering any action when clicked.
What I've tried
Using destructured props throughout project
const {
sortBy,
sortSelected,
} = this.state
Currently passing the following props from SearchPage.js to child SideBar.js:
sortByValues={sortBy} // Array of sort criteria options stored in state
sortSelected={sortSelected} // Currently selected sort criteria from drop-down
handleSortSelected={this.handleSortSelected} // event handler to update state with currently selected sort criteria
sortAsc={this.sortAsc} // function to trigger Asc sort onClick
sortDesc={this.sortDesc} // function to trigger Asc sort onClick
SortMenu.js Code
import React, { Component } from 'react';
export default class SortMenu extends Component {
render() {
const {
sortByValues,
handleSortSelected,
sortAsc,
sortDesc,
} = this.props
const options = sortByValues.map(option => <option value={option} key={option}>{option}</option>)
return (
<aside>
{/* DropDown Sort By */}
<select className='dropdown'
onChange={handleSortSelected}>
{options}
</select>
{/* Sort Ascending/Descending Buttons */}
<button className='sortBtn' value='ascending' onClick={sortAsc}>Ascending</button>
<button className='sortBtn' value='descending' onClick={sortDesc}>Descending</button>
</aside >
)
}
}
I feel it's important to note that we are using strictly class components and therefore are not using constructor(props) in this project. Instead, I am using arrow functions throughout to implicitly bind this where needed.
To reiterate my goal: I need to dynamically render the pokemon list based on the sort criteria selected in the drop-down list as well as the ascend/descend button clicked. The list should update onClick.
I have been working on this for 3 days and have gone through so many iterations of sort functions that I can't even see straight anymore. I'm thinking this might be a simple over-sight due to the sheer amount of code I'm writing as well as the fact that this is a new language (I'm already proficient in Vanilla JS).
Any guidance here would be GREATLY appreciated as I'm really at a wall at this point. I don't know what else to try in order to render the sorted list.
Update
Also, would it be easier if I were to create a toggle button instead of two separate buttons for Asc/Desc?
Thank you!
Update 2
Currently I am rendering my pokemon objects in a module component that I'm calling within the SearchPage.js component. Here is the code for the Pokemon List:
export default class PokemonList extends Component {
render() {
const { filteredPokemon } = this.props
return (
<content className='pokemon-list float'>
{filteredPokemon.map(pokeObject =>
<PokeItem
key={pokeObject._id}
pokeImage={pokeObject.url_image}
pokeName={capFirstLetter(pokeObject.pokemon)}
pokeType={capFirstLetter(pokeObject.type_1)}
pokeHp={pokeObject.hp}
pokeAtt={pokeObject.attack}
pokeDef={pokeObject.defense}
/>)}
</content>
);
}
}
This is pulling the filtered pokemon from my filter method on SearchPage.js:
const filteredList = pokeData.filter(pokeObject => {
return pokeObject['pokemon'].includes(this.state.searchQuery) || pokeObject['type_1'].includes(tFilterSelected);
});
To sort the items, I'm using two separate arrow functions. These are housed in the first section of the SearchPage.js component (outside of render and return):
sortAsc = () => {
this.setState(prevState => {
this.state.pokeData.sort((a, b) => (a[this.state.sortSelected] - b[this.state.sortSelected]))
})
}
sortDesc = () => {
this.setState(prevState => {
this.state.pokeData.sort((a, b) => (b[this.state.sortSelected] - a[this.state.sortSelected]))
})
}
Update 3
Added project to CodeSandbox. Can view here: https://codesandbox.io/s/pokedex-react-crflb
A comparator function that subtracts the arguments would work fine for sorting numbers but would fail on strings since it would return NaN.
sortAsc = () => {
const { pokeData, sortSelected } = this.state
this.setState({
pokeData: [...pokeData].sort((a, b) => {
if (a[sortSelected] > b[sortSelected]) {
return 1
}
if (a[sortSelected] < b[sortSelected]) {
return -1
}
return 0
})
})
}
sortDesc = () => {
const { pokeData, sortSelected } = this.state
this.setState({
pokeData: [...pokeData].sort((a, b) => {
if (a[sortSelected] > b[sortSelected]) {
return -1
}
if (a[sortSelected] < b[sortSelected]) {
return 1
}
return 0
})
})
}
Working CodeSandbox

Customize Array State in ReactJs with ES6

i am fetching some data (in array) from api and assigning it to state called items. then calling an Item component for each item of the state by following code:
<div>
{items.length} results found for {search_term}
{items.map((item) =>
(item.active_price > 0 && item.show) ? <Item key={item.id} details={item} items={items} setItems={setItems}/> : ""
)}
</div>
The array looks like this:
Next plan is adding a remove button in Item component, which will change value of show to false (for the item clicked) in the items state. What I am thinking is, as the state will change, the items state will be re-rendered and the item turned to false will be hidden from view. I am adding a onCLick event listener on the button and the function is:
const handleClick = () => {
console.log(({...props.items,[props.details.number - 1]:false}))
// props.setItems(prevState=>({...prevState,[props.details.number - 1]:false}))
}
I'll call props.setItems later but for now, I am trying to see if I can just edit that element to turn the show into false. the code above replace the whole index with false.
in the onClick function, I only want to edit the value of show, not replace the entire entry. I have tried:
({...props.items,[props.details.number - 1][show]:false})
and
({...props.items,[props.details.number - 1].show:false})
It shows syntax error before [show] and .show respectively. How can I edit my handleClick function to edit the value of show properly.
Thanks.
It looks like you are converting your array into an object in your current method. (the first screen shot is an array, the second is an object). You're going to run into issues there.
To just remove the item from the array, it would be easiest just to use .filter();
const handleClick = () => {
props.setItems(
prevState => prevState.filter(item => item.number !== props.details.number)
);
}
To set the show property is a bit more complicated. This is pretty standard way to do so without mutating.
const handleClick = () => {
props.setItems(prevState => {
const itemIndex = prevState.findIndex(
item => item.number == props.details.number
);
// Using 'props.details.number - 1' will not be a valid index when removing items
return [
...prevState.slice(0, itemIndex), // Your state before the item being edited
{...prevState[itemIndex], show: false}, // The edited item
...prevState.slice(itemIndex + 1) // The rest of the state after the item
];
});
}
You can try the following syntax of code, assuming items is state here-
const [items,setItems]=.....
The following is the code
const handleClick = () => {
setItems({
...items,
show: false,
})}
It will update the items state with value show as false.

How do I update an array using useState?

I have the following scenario:
DisplayList component, which renders a list. The list record is passed in via props (this contains the list name, description, author etc), along with the initial items to be listed, and the current 'mode' (can be 'r', 'g' or 'a'). The component displays the list information, and then renders a DisplayItem component for each list item. The DisplayItem component also takes a callback function, so that when the user clicks on an item in the list, it becomes 'tagged' with the current mode (ie the 'mode' property of the item changes).
const DisplayList = (props) => {
// Get list object (contains list name etc)
// and current mode (r, a or g)
const { list, currentMode } = props
// Array of items to show
// This is an array of objects, with each object representing a single item
// eg {name: itemname, mode: r}
const [ items, setItems] = useState(props.items)
// Callback function - triggered when the rendered DisplayItem is clicked
// This changes the 'mode' of the item to the current mode
function handleItemClick(index) {
items[index].mode = currentMode
setItems(items) // At this point the list of items should re-render
}
}
return (
<div className="displayItem">
{
items.map(item => <DisplayItem key={uuid()} item={item} handleCellClick={handleItemClick} />)
}
</div>
)
}
The problem I'm having is that clicking an item seems to trigger the handleItemClick function to update the itemList, but when it gets to setItems, it doesn't re-render.
You are not passing the index to your callback.
try this:
items.map({item, index} => <DisplayItem key={uuid()} item={item} handleCellClick={() => handleItemClick(index)} />)
edited as pointed by Brian Thompson at the comments

Resources