I'm using React DnD and Redux (using Kea) to build a formbuilder. I've the drag & drop portion working just fine, and I've managed to dispatch an action when an element drops, and I render the builder afterwards using state that the dispatch changed. However, in order to render elements in the correct order, I (think I) need to save the dropped elements position relative to it's siblings but I'm unable to figure out anything that isn't absolutely insane. I've experimented with refs and querying the DOM with the unique ID (I know I shouldn't), but both approaches feel pretty terrible and do not even work.
Here's a simplified representation of my app structure:
#DragDropContext(HTML5Backend)
#connect({ /* redux things */ })
<Builder>
<Workbench tree={this.props.tree} />
<Sidebar fields={this.props.field}/>
</Builder>
Workbench:
const boxTarget = {
drop(props, monitor, component) {
const item = monitor.getItem()
console.log(component, item.unique, component[item.unique]); // last one is undefined
window.component = component; // doing it manually works, so the element just isn't in the DOM yet
return {
key: 'workbench',
}
},
}
#DropTarget(ItemTypes.FIELD, boxTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}))
export default class Workbench extends Component {
render() {
const { tree } = this.props;
const { canDrop, isOver, connectDropTarget } = this.props
return connectDropTarget(
<div className={this.props.className}>
{tree.map((field, index) => {
const { key, attributes, parent, unique } = field;
if (parent === 'workbench') { // To render only root level nodes. I know how to render the children recursively, but to keep things simple...
return (
<Field
unique={unique}
key={key}
_key={key}
parent={this} // I'm passing the parent because the refs are useless in the Field instance (?) I don't know if this is a bad idea or not
/>
);
}
return null;
}).filter(Boolean)}
</div>,
)
// ...
Field:
const boxSource = {
beginDrag(props) {
return {
key: props._key,
unique: props.unique || shortid.generate(),
attributes: props.attributes,
}
},
endDrag(props, monitor) {
const item = monitor.getItem()
const dropResult = monitor.getDropResult()
console.log(dropResult);
if (dropResult) {
props.actions.onDrop({
item,
dropResult,
});
}
},
}
#connect({ /* redux stuff */ })
#DragSource(ItemTypes.FIELD, boxSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}))
export default class Field extends Component {
render() {
const { TagName, title, attributes, parent } = this.props
const { isDragging, connectDragSource } = this.props
const opacity = isDragging ? 0.4 : 1
return connectDragSource(
<div
className={classes.frame}
style={{opacity}}
data-unique={this.props.unique || false}
ref={(x) => parent[this.props.unique || this.props.key] = x} // If I save the ref to this instance, how do I access it in the drop function that works in context to boxTarget & Workbench?
>
<header className={classes.header}>
<span className={classes.headerName}>{title}</span>
</header>
<div className={classes.wrapper}>
<TagName {...attributes} />
</div>
</div>
)
}
}
Sidebar isn't very relevant.
My state is a flat array, consisting of objects that I can use to render the fields, so I'm reordering it based on the element positions in the DOM.
[
{
key: 'field_type1',
parent: 'workbench',
children: ['DAWPNC'], // If there's more children, "mutate" this according to the DOM
unique: 'AWJOPD',
attributes: {},
},
{
key: 'field_type2',
parent: 'AWJOPD',
children: false,
unique: 'DAWPNC',
attributes: {},
},
]
The relevant portion of this question revolves around
const boxTarget = {
drop(props, monitor, component) {
const item = monitor.getItem()
console.log(component, item.unique, component[item.unique]); // last one is undefined
window.component = component; // doing it manually works, so the element just isn't in the DOM yet
return {
key: 'workbench',
}
},
}
I figured I'd just get the reference to the element somehow, but it doesn't seem to exist in the DOM, yet. It's the same thing if I try to hack with ReactDOM:
// still inside the drop function, "works" with the timeout, doesn't without, but this is a bad idea
setTimeout(() => {
const domNode = ReactDOM.findDOMNode(component);
const itemEl = domNode.querySelector(`[data-unique="${item.unique}"]`);
const parentEl = itemEl.parentNode;
const index = Array.from(parentEl.children).findIndex(x => x.getAttribute('data-unique') === item.unique);
console.log(domNode, itemEl, index);
});
How do I achieve what I want?
Apologies for my inconsistent usage of semicolons, I don't know what I want from them. I hate them.
I think the key here is realizing that the Field component can be both a DragSource and a DropTarget. We can then define a standard set of drop types that would influence how the state is mutated.
const DropType = {
After: 'DROP_AFTER',
Before: 'DROP_BEFORE',
Inside: 'DROP_INSIDE'
};
After and Before would allow re-ordering of fields, while Inside would allow nesting of fields (or dropping into the workbench).
Now, the action creator for handling any drop would be:
const drop = (source, target, dropType) => ({
type: actions.DROP,
source,
target,
dropType
});
It just takes the source and target objects, and the type of drop occurring, which will then be translated into the state mutation.
A drop type is really just a function of the target bounds, the drop position, and (optionally) the drag source, all within the context of a particular DropTarget type:
(bounds, position, source) => dropType
This function should be defined for each type of DropTarget supported. This would allow each DropTarget to support a different set of drop types. For instance, the Workbench only knows how to drop something inside of itself, not before or after, so the implementation for the workbench could look like:
(bounds, position) => DropType.Inside
For a Field, you could use the logic from the Simple Card Sort example, where the upper half of the DropTarget translates to a Before drop while the lower half translates to an After drop:
(bounds, position) => {
const middleY = (bounds.bottom - bounds.top) / 2;
const relativeY = position.y - bounds.top;
return relativeY < middleY ? DropType.Before : DropType.After;
};
This approach also means that each DropTarget could handle the drop() spec method in the same manner:
get bounds of the drop target's DOM element
get the drop position
calculate the drop type from the bounds, position, and source
if any drop type occurred, handle the drop action
With React DnD, we have to be careful to appropriately handle nested drop targets since we have Fields in a Workbench:
const configureDrop = getDropType => (props, monitor, component) => {
// a nested element handled the drop already
if (monitor.didDrop())
return;
// requires that the component attach the ref to a node property
const { node } = component;
if (!node) return;
const bounds = node.getBoundingClientRect();
const position = monitor.getClientOffset();
const source = monitor.getItem();
const dropType = getDropType(bounds, position, source);
if (!dropType)
return;
const { onDrop, ...target } = props;
onDrop(source, target, dropType);
// won't be used, but need to declare that the drop was handled
return { dropped: true };
};
The Component class would end up looking something like this:
#connect(...)
#DragSource(ItemTypes.FIELD, {
beginDrag: ({ unique, parent, attributes }) => ({ unique, parent, attributes })
}, dragCollect)
// IMPORTANT: DropTarget has to be applied first so we aren't receiving
// the wrapped DragSource component in the drop() component argument
#DropTarget(ItemTypes.FIELD, {
drop: configureDrop(getFieldDropType)
canDrop: ({ parent }) => parent // don't drop if it isn't on the Workbench
}, dropCollect)
class Field extends React.Component {
render() {
return (
// ref prop used to provide access to the underlying DOM node in drop()
<div ref={ref => this.node = ref}>
// field stuff
</div>
);
}
Couple things to note:
Be mindful of the decorator order. DropTarget should wrap the component, then DragSource should wrap the wrapped component. This way, we have access to the correct component instance inside drop().
The drop target's root node needs to be a native element node, not a custom component node.
Any component that will be decorated with the DropTarget utilizing configureDrop() will require that the component set its root node's DOM ref to a node property.
Since we are handling the drop in the DropTarget, the DragSource just needs to implement the beginDrag() method, which would just return whatever state you want mixed into your application state.
The last thing to do is handle each drop type in your reducer. Important to remember is that every time you move something around, you need to remove the source from its current parent (if applicable), then insert it into the new parent. Each action could mutate the state of up to three elements, the source's existing parent (to clean up its children), the source (to assign its parent reference), and the target's parent or the target if an Inside drop (to add
to its children).
You also might want to consider making your state an object instead of an array, which might be easier to work with when implementing the reducer.
{
AWJOPD: { ... },
DAWPNC: { ... },
workbench: {
key: 'workbench',
parent: null,
children: [ 'DAWPNC' ]
}
}
Related
I'm trying to delve deeper than the basics into React and am rebuilding a Tree library I had written in plain JS years back. I need to expose an API to users so they can programmatically add/remove nodes, select nodes, etc.
From what I've learned, a ref and context is a good approach. I've built a basic demo following the examples (without ref, for now) but I'm seeing every single tree node re-render when a selection is made, even though no props have changed for all but one.
I've tried a few things like memoizing my tree node component, etc but I feel like I'm failing to understand what's causing the re-render.
I'm using the react dev tools to highlight renders.
Here's a codesandbox demo.
My basic tree node component. I essentially map this for every node I need to show. On click, this calls select() from my context API. The rerender doesn't happen if that select() call is disabled.
const TreeNodeComponent = ({ id, text, children }) => {
console.log(`rendering ${id}`);
const { select } = useTreeContext();
const onClick = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
select([id]);
},
[select, id]
);
return (
<div className="tree-node" onClick={onClick}>
{text}
{children ? <TreeNodesComponent nodes={children} /> : ""}
</div>
);
};
The important part of my context is the provider:
const TreeContextProvider = ({ children, nodes = [], selectedNodes }) => {
const [allNodes] = useState(nodes);
const [allSelectedNodes, setSelectedNodes] = useState(selectedNodes || []);
const api = useMemo(
() => ({
selected: () => allSelectedNodes,
select: (nodes) => {
setSelectedNodes(Array.from(new Set(allSelectedNodes.concat(nodes))));
}
}),
[allSelectedNodes]
);
const value = useMemo(
() => ({
nodes: allNodes,
selectedNodes: allSelectedNodes,
...api
}),
[allNodes, allSelectedNodes, api]
);
return <TreeContext.Provider value={value}>{children}</TreeContext.Provider>;
};
Again, when the call to setSelectedNodes is disabled, the rerenders don't happen. So the entire state update is triggering the render, yet individual props to all but one tree component do not change.
Is there something I can to improve this? Imagine I have 1000 nodes, I can't rerender all of them just to mark one as selected etc.
It seems I just can't get my head around stale state issues in React as it relates to event handlers and hooks. I conceptually understand what is happening–there is a closure that is capturing the starting value of a state value, and isn't updating when I expect it to.
I am creating a NavBar component onto which I want to add keyboard controls to allow accessibility for sub menus and so forth. I will show the code and then describe what is happening / not happening. I'll also link to a codesandbox for easier forking and debugging.
CodeSandbox
NavBar
const NavBar: React.FC<Props> = ({ children, label }) => {
const {
actions: { createNavItemRef },
state: { activeSubMenuIndex, navBarRef },
} = useNav();
console.log('NAV.BAR', { activeSubMenuIndex });
return (
<nav aria-label={label} ref={navBarRef}>
<NavList aria-label={label} role="menubar">
{children} // NavItems
</NavList>
</nav>
);
};
NavItem
const NavItem: React.FC<Props> = ({ children, hasSubMenu, to }) => {
const ChildrenArray = React.Children.toArray(children);
const {
actions: { handleSelectSubMenu },
state: { activeSubMenuIndex },
} = useNav();
const handleSubMenuToggle = () => {
handleSelectSubMenu(index);
};
return (
<li ref={ref} role="none">
<>
<ParentButton
aria-expanded={activeSubMenuIndex === index}
aria-haspopup="true"
onClick={handleSubMenuToggle}
role="menuitem"
tabIndex={index === 0 ? 0 : -1}
>
{ChildrenArray.shift()}
</ParentButton>
{ChildrenArray.pop()}
</>
</li>
);
};
UseNav
function useNav() {
const navBarRef = useRef<HTMLUListElement>(null);
const [activeSubMenuIndex, setActiveSubMenuIndex] = useState<number | undefined>();
const handleSelectSubMenu = (index?: number) => {
if (!index || activeSubMenuIndex === index) {
setActiveSubMenuIndex(undefined);
} else {
setActiveSubMenuIndex(index);
}
};
useEffect(() => {
const navbar = navBarRef?.current;
navbar?.addEventListener('keydown', () => {
console.log("UseNav", { activeSubMenuIndex });
});
// return () => remove event listener
}, [navBarRef, activeSubMenuIndex]);
return {
actions: {
createNavItemRef,
handleSelectSubMenu,
},
state: {
activeSubMenuIndex,
navBarRef,
},
};
}
This is a somewhat stripped down version of my set up. Ultimately, here's what's going on.
Expectation
I tab onto the first NavItem and it becomes focused. I hit an arrow key (for example) and the log UseNav { activeSubMenuIndex }) logs out correctly as undefined.
Then I click on the NavItem which contains a sub menu. The activeSubMenuIndex updates and in the NavItem the correct sub menu is displayed (based on the activeSubMenuIndex === index conditional).
However, I would expect the NavBar { activeSubMenuIndex }) to log out as well when this NavItem is clicked. But it doesn't.
With the sub menu visible, I hit another arrow key and when the UseNav log is displayed, I would expect it to contain the correct activeSubMenuIndex value, but it is still undefined.
Ultimately, I will need to addEventListeners for keyPress on the NavBar in order to assign keyboard navigation throughout. But if I can't even get the state values updating correctly at this MVP level, then I can't really move forward without making this more cumbersome to work with and debug later.
I know this is an issue of stale state, but I can't find any good articles on this topic that isn't just incrementing a number within the same file. So any help in finally cracking through this wall would be amazing.
Thank you!
Looks like this issue stemmed from the use of the useNav() hook. When I call this hook inside of NavBar references and values are instantiated once. When I call the hook again in NavItem those same refs and values are instantiated again.
In this case, instead of a hook, it would make more sense to wrap this in a context in order to keep the logic out from the UI but keep the components consistent in their data sources.
I'm fairly new to React and I'm having some trouble understanding exactly why an unchanging component is getting rerendered, even though I'm using the React.memo higher-order component.
I have a sidebar which contains a number of row elements. Rows contain data that's used in other components; all components share the 'selection' status of the rows. In the sidebar, I change the styling to show the selection state of every element.
Everything behaves as expected, but performance scales poorly as the list gets longer. I think part of this is due to React re-rendering every row element in the sidebar list, including ones whose selection state has not changed. I thought I could prevent this re-rendering by using React.memo, but it doesn't seem to make a difference.
Here is the code for each list entry:
import React from 'react';
// The only props that might change value are the labels string and
// the styles rowStyle and labelStyle, which caller populates
// with 'selected' or 'unselected' styles based on row state
const Row = React.memo(({
rowId, labels = "", rowStyle = {}, labelStyle = {},
onClicked // callback from grandparent, which updates selections (w/ modifier keys)
}) => {
console.log(`Rendering row ${rowId}`) // to report when rows rerender
return (
<div
key={rowId}
style={rowStyle}
onClick={(event) => onClicked(rowId, event)}
>
<span>{rowId}</span>
<span style={labelStyle}>{ labels }</span>
</div>
);
})
export default Row;
This component is called from a parent which represents the entire sidebar list. In order to minimize the amount of needless function calls (and make very clear that there's nothing with any side effects happening within the individual rows), I build a list of tuples for each row that has its id, style, labels, and label-style.
The contents of the list are passed to the Row component, and most of the time should be identical between calls (thus triggering memoization and avoiding the rerender), but don't seem to be.
import React from 'react';
import Row from '../pluginComponents/Row';
import Styles from './common/Styles'; // to ensure the references aren't changing
// onClicked is passed in from the parent component and handles changing the selections
const ListDiv = React.memo(({ rowIds, onClicked, rowLabels, styling, selections }) => {
const tuples = rowIds.reduce((priors, rowId) => {
return {
...priors,
[rowId]: {
'style': Styles.unselectedStyle,
'labelStyle': Styles.unselectedLabelStyle,
'labels': ((rowLabels[rowId] || {}).labels || []).join(", ")
}
}
}, {});
Object.keys(selections).forEach((rowId) => {
if (!tuples[rowId]) return;
tuples[rowId]['style'] = Styles.selectedStyle;
tuples[rowId]['labelStyle'] = Styles.selectedLabelStyle;
});
return (
<div style={styling}>
{rowIds.map((rowId) => (
<Row
key={rowId}
rowId={rowId}
labels={tuples[rowId]['labels']}
rowStyle={tuples[rowId]['style']}
labelStyle={tuples[rowId]['labelStyle']}
onClicked={onClicked}
/>
))}
</div>
)
})
const RowList = ({ list, selections = {}, onClicked, labels={}, styling }) => {
if (!list) return (<div>Empty list</div>);
return (
<div>
<ListDiv
rowIds={list}
onClicked={onClicked}
rowLabels={labels}
styling={styling}
selections={selections}
/>
</div>
);
}
export default RowList;
which is itself called from a grandparent class that manages all the state:
const Grandparent = (props) => {
...
return (
...
<div>
{
(status !== 'complete') ? (
<div><CircularProgress /></div>
) : (
<RowList list={data.list}
selections={selections} // tracked with useState
onClicked={handleClicked} // calls some functions defined in this component
labels={data.labels || {}}
styling={foo}
/>
)
}
...
);
...
Why are my ought-to-be-memoized entries of the Row component getting rerendered, and what can I do to fix it?
The onClicked function in the Grandparent could be getting recreated on each render, so making your row component re-render as well.
The solution is to use React.useCallback in the Grandparent.
const handleClicked = React.useCallback(() => {
...
}, [a, b])
Where a and b are dependencies that if change will require a re-render.
React useCallback docs
I have some code that takes an array of JSX as props. The component is supposed to ensure that all JSX is rendered in the order they appear in the array. In order to do this, all of my components have an optional onLoad function they can take as a prop. When I initially create the array of JSX in
a parent component, this function isn't being added, so that I don't have to write the same function in all of these different parent classes. So I pass them to my component that handles the load order, but it needs a way to add the onLoad attribute to everything in the array.
I have tried mapping through the array and using child.setAttribute on each element, but I get the error: setAttribute is not a function
I have also tried child.onLoad = this.childDidLoad which resulted in the error: Cannot add property onLoad, object is not extensible
I have tried spreading, which you should see below. This doesn't throw errors, but appears to never add the attribute, as that section of code is never reached.
class StandardPage extends Component {
static propTypes = {
children: PropTypes.array.isRequired, // All child components to render
};
state = {
childrenLoaded: 0, // Number of children loaded
};
/**
* When this method is called, a child has updated.
* Take previous value of number of children loaded, and add one.
* Store as new value in state
*/
childDidLoad() {
const {childrenLoaded} = this.state;
this.setState({
childrenLoaded: childrenLoaded + 1,
});
}
/**
* Slice children array to only return those that have loaded, plus one.
* THIS CONTAINS THE PROBLEM!!!
* #return {array} JSX array
*/
getLoaded() {
const {childrenLoaded} = this.state;
const {children} = this.props;
this.childDidLoad = this.childDidLoad.bind(this);
const loaded = children.slice(0, childrenLoaded + 1);
return loaded.map((child) => {
return {
...child, // THIS IS THE PROBLEM SPOT
onLoad: this.childDidLoad,
};
});
}
/**
* Visual component
* #return {jsx}
*/
render() {
const components = this.getLoaded();
return (
<div className="page">
<Nav>
{components}
</Nav>
</div>
);
}
}
example use:
class ExamplePage extends Component {
state = {
children: [
<Section
key="origin"
restEndpoint="origin"
id={originID}
bgColor={(isYellow) ? 'yellow' : 'white'}
txtJustify={true}
/>,
<Section
key="funFacts"
restEndpoint="funfacts"
id={funFactID}
bgColor={(isYellow) ? 'yellow' : 'white'}
txtJustify={true}
/>
],
};
/**
* Visual component
* #return {jsx}
*/
render() {
const {children} = this.state;
return (
<StandardPage children={children} />
);
}
}
Right now, the first child component gets displayed, and no others, as the childDidLoad method is never getting called.
return loaded.map((child) => {
return React.cloneElement(child, { onLoad: this.childDidLoad });
});
This may work for you to pass the method as a prop to your Component. I gonna setup a test on my local machine for further investigations if it doesnt help. greetz
I'm considering using Redux for my app, but there's a common use case that I'm not sure how to handle with it. I have a component that displays some object and allows the user to edit it. Every action will create a shallow copy of the object, but what then? How is the component supposed to know how to update the storage with it? In the samples I see that the component is passed a key instead of the actual object, but doesn't that break the concept of incapsulation, since a component isn't supposed to know where it's state/props come from? I want the component to be fully reusable, so it receives an object and information on how to update it in a more general form, which seems to be awkward to implement with Redux (I'm going to have to pass write callbacks to every component, and then chain them somehow).
Am I using Redux wrong, or is there a more suitable alternative for this use case? I'm thinking of making one myself (where every state object knows it's owner and key via some global WeakMap), but I don't want to be reinventing the wheel.
For instance, if my storage looks like this:
Storage = {
items: {
item1: { ... },
item2: { ... },
...
},
someOtherItems: {
item1: { ... },
...
},
oneMoreItem: { ... },
};
I want to be able to display all item objects with the same component. But the component somehow has to know how to write it's updated item back to the storage, so I can't just pass it item1 as key. I could pass a callback that would replace a specific item in the (cloned) storage, but that doesn't work well if, for instance, I have a component that displays a list of items, since I would have to chain those callbacks somehow.
This is a common use case, and yes - you're missing the point here. react/redux makes this really easy.
I usually structure it as follows: Components receive a modelValue object prop and changeValue function prop. The former is the current value, the latter is the function we call to change the value. These props are going to be supplied by redux.
Now we write a connect hoc (higher order component), a simple example might look like this:
const mapStateToProps = (state, ownProps) => {
return {
modelValue: _.get(state, ownProps.model),
};
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
changeValue: (val) => dispatch({
type: "your/reducer/action",
model: ownProps.model,
value: val,
})
};
};
const mergeProps = (stateProps, dispatchProps, ownProps) => {
return {
...stateProps,
...dispatchProps,
...ownProps,
};
};
const MyConnectedComponent = connect(mapStateToProps, mapDispatchToProps, mergeProps)(MyGenericComponent);
This is an example where we pass in a model string to the hoc, and it wires up modelValue and changeValue for us. So now all we need to do is pass in a model like "some.javascript.path" to our component and that's where it will get stored in the state. MyGenericComponent still doesn't know or care about where it's stored in the state, only MyConnectedComponent does.
Usage would be as follows:
<MyConnectedComponent model="some.path.in.the.state" />
And inside MyGenericComponent just consume modelValue for the current value, and execute changeValue to change the value.
Note that you need to also wire up a redux reducer to handle your/reducer/action and actually do the update to the state, but that's a whole other topic.
Edit
You mentioned that you need sub components to be aware of the parent state, this can be achieved by passing model via context. The following examples are using recompose:
const mapStateToProps = ...
const mapDispatchToProps = ...
const mergeProps = ...
const resolveParentModel = (Component) => {
return (props) => {
// we have access to 'model' and 'parentModel' here.
// parentModel comes from parent context, model comes from props
const { parentModel, model } = props;
let combinedModel = model;
// if our model starts with a '.' then it should be a model relative to parent.
// else, it should be an absolute model.
if (model.startsWith(".")) {
combinedModel = parentModel + model;
}
return <Component {...props} model={combinedModel} />;
}
}
const myCustomHoc = (Component) => (
// retrieve the current parent model as a prop
getContext({ parentModel: React.PropTypes.string })(
// here we map parent model and own model into a single combined model
resolveParentModel(
// here we map that combined model into 'modelValue' and 'changeValue'
connect(mapStateToProps, mapDispatchToProps, mergeProps)(
// we provide this single combined model to any children as parent model so the cycle continues
withContext({ parentModel: React.PropTypes.string }, (props) => props.model)(
Component
)
)
)
)
);
In summary, we pass a context value parentModel to all children. Each object maps parent model into it's own model string conditionally. Usage would then look like this:
const MyConnectedParentComponent = myCustomHoc(MyGenericParentComponent);
const MyConnectedSubComponent = myCustomHoc(MyGenericSubComponent);
<MyConnectedParentComponent model="some.obj">
{/* the following model will be resolved into "some.obj.name" automatically because it starts with a '.' */}
<MyConnectedSubComponent model=".name" />
</MyConnectedParentComponent>
Note that nesting this way could then go to any depth. You can access absolute or relative state values anywhere in the tree. You can also get clever with your model string, maybe starting with ^ instead of . will navigate backwards: so some.obj.path and ^name becomes some.obj.name instead of some.obj.path.name etc.
Regarding your concerns with arrays, when rendering arrays you almost always want to render all items in the array - so it would be easy enough to write an array component that just renders X elements (where X is the length of the array) and pass .0, .1, .2 etc to each item.
const SomeArray = ({ modelValue, changeValue }) => (
<div>
{modelValue.map((v, i) => <SomeChildEl key={i} model={"." + i} />)}
<span onClick={() => changeValue([...modelValue, {}])} >Add New Item</span>
</div>
);