Reducing renders in child components of a context - javascript

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.

Related

Using react components to render data controlled by third party library

I'm building a React component that needs to consume data from a tree library built in vanilla JS. This library holds and manages data for all "tree nodes" and they're state - expanded/collapsed, selected, hidden, etc.
I'm unsure how to approach building react components because they ideally control their own state or use a store designed for use in react.
Here's a super simple example of data that might be loaded into the tree library.
[{
id: 1,
text: 'Node 1'
}, {
id: 2
text: 'Node 2',
state: {
selected: true
}
}]
It gets loaded into the tree lib via the constructor new Tree(nodes); and the tree lib provides a ton of methods to work with it: tree.deselect(2) and tree.selected() // -> []
I've toyed around with some basic components to render this example:
I start with <TreeNodes nodes={tree.nodes()} />
const TreeNodes = ({ nodes }: TreeNodesProps) => {
return (<>{ nodes.map(node => <TreeNode key={node.id} node={node} />) }</>);
}
const TreeNode = ({ node }: TreeNodeProps) => {
const onClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
node.toggleSelect();
}
return <div className={clsx({ selected: node.selected()})} onClick={onClick}>{node.text}</div>
}
The tree library fires events like node.selected to let me know when something has changed in the data.
My question is, what's the best/proper way to then sync my data to react components?
I was debating listening for all tree events and updating a state object in the root component but that feels wrong:
const [nodes, setNodes] = useState(tree.nodes());
this.tree.on('node.selected', () => {
setNodes(tree.nodes())
});
I honestly don't feel adding a listener is wrong as long as it works fine. Also I think you should wrap it up in a useEffect this way:
function MyApp() {
const [nodes, setNodes] = useState(tree.nodes());
useEffect(() => {
tree.on("node.selected", () => {
setNodes(tree.nodes());
});
return () => {/* remove listener here */}
}, []);
}
You either have this solution or you will have to make all changes to the data by yourself.

React/Redux Toolkit render issue when deleting state from an array

Problem: Component render starts to drift from actual state
Desired Output: Component render matches state.
So. I'm going to give a bit of a high-level overview with pseudocode as this issue is quite complex, and then I'll show the code.
I have a main form, and this form has an array of filter-states that are renderable in their own components. These filter-states are a one-to-many relationship with the form. The form has-many filter-states.
form: {
filters: [
filter1,
filter2
]
}
Say you want to remove an item from the state, you would do something like so in the reducer (redux)
state.form.filters.filter(f => f.id != action.payload.id)
All good. The state is updated.
Say, you want to render this state, you would do something like so:
// component code ommited, but say you get your form state from redux into the component
formState.filters.map(filter => <FilterComponent filter={filter}/>
All good. your filters are being injected into the component and everyone is happy
Now. This is where it gets weird pretty quickly.
There is a button on my FilterComponent, that says delete. This delete button goes to the reducer, runs the code to delete the filter from the formstate (as you saw above), and yes, it DOES work. The state gets updated, BUT, the UI (the array of components) starts to drift from the state. The UI shows previously deleted states, and states that should be persisted are not shown (but in the redux tab on chrome, the state is CORRECT...!)
The UI acts as if the array of states is being pop()'d; no matter how you remove the states, it will remove the final state in component render.
Now, for the code.
// This takes a list of filters from the form state and loads them into individual form components
const Filters: NextPage<Filters> = () => {
const formState = useSelector((state: any) => state.form.formState)
// In the hope that state change will force reload components, but no avail
useEffect(() => {
console.log("something has been reloaded")
}, [formState])
return (
<>
{formState.form.map((filter, i) => {
return <FilterForm defaultState={filter} key={i} index={i} />
})}
</>
);
};
export default Filters;
The form for these individual states:
Please note, this is obviously redacted a lot but the integral logic is included
const FilterForm: NextPage<FilterFormProps> = ({ defaultState, index }) => {
const formState = useSelector((state: any) => state.form.formState)
// Local component state; there are multiple forms so the state should be localised
const [FilterState, setFilterState] = useState(defaultState)
const handleDelete = (e) => {
dispatch(deleteFilter(filterState.id))
}
const updateParentState = async () => {
dispatch(updateForm(filterState))
}
useEffect(() => {
updateParentState()
}, [filterState])
return (
<CloseButton position="absolute" right="0" top="25px" onClick={handleDelete} name={filterState.id} />
<Input
name="filter_value"
onChange={handleOnChange} // does standard jazz
value={filterState.filter_value} // standard jazz again
/>
)
}
Now what happens is this: if I click delete, redux updates the correct state, but the components display the deleted state input. Ie, take the following:
filter1: {filter_value: "one"}
filter2: {filter_value: "two"}
filter3: {filter_value: "three"}
these filters are rendered in their own forms.
Say, I click delete on filter1.
filter1 will be deleted from redux, but the UI will show two forms: one for filter1 and one for filter2.
This drift from UI to state baffles me. Obviously I am doing something wrong, can someone spot what it is?!
So, I fixed the issue.
As it turns out, there isnt really an explanation for why the above behaved as it does, but it does warrant for a better implementation.
The issue was as follows; the redux state was conflicting with the local state of the rendered components it was injected in. Why it did, is another story. Somehow, while injecting the redux state into the component and assigning it to the local state, the states went a bit haywire and drifted apart.
The solution was to get rid of the local state (filterState), the updateParentState function call and rather to update the localised state directly through the parent state that it resides in.
The new component looked something like the following:
const FilterForm: NextPage<FilterFormProps> = ({ state, index }) => {
const handleDelete = (e) => {
dispatch(deleteFilter(filterState.id))
}
const handleChange = (e) => {
dispatch(updateFormFilterState({ ...state, [e.target.name]: e.target.value }))
}
return (
<CloseButton position="absolute" right="0" top="25px" onClick={handleDelete} />
<Input
name="filter_value"
onChange={handleChange}
value={state.filter_value}
/>
)
}
Hope this answer helps someone with the same issue as me.

how to call 2 different methods of Child from Parent using useImperativeHandler?

I have a parent component where I need to call 2 methods of its Child. I am able to call one using useImperativeHandler like
const Parent = () => {
const childRef = useRef();
return (
<div>
<Child ref={childRef} />
<Button onClick={() => childRef.current.methodOne()}>
Submit
</Button>
</div>
);
};
and then in Child Compoennt
const Child = forwardRef((props, ref) => {
useImperativeHandle(
ref,
() => ({
methodOne() {
// some code
},
}),
[]
);
return;
});
So far it works very ok.
But I want another button in the Parent component to call a second method(let's call it methodTwo) in the same Child. How can I do it?
Edit: Found the answer. userImperativeHandle takes multiple methods like this and you can call them normally in Parent Component.
useImperativeHandle(ref, () => ({
methodOne: () => {
},
methodTwo: () => {
}
}));
check this example , i tried to make it vary simple to understand , but if you get into trouble i am happy to even help you on a zoom call
this is the basic concept in react , to pass a method from parent to child
https://stackblitz.com/edit/react-yh2wau?file=src%2FChild.jsx
i am passing a console.log function from parent to child in this example
and the 2 buttons call different methods like what you want to do .
hopes that makes sense!
for more information and learning i suggest going through the documentation of React
try this tutorial
it will give you the basics

Is there any practical way to call `React.createContext()` within a component?

Let's say I want to create a UI component for an "accordion" (a set of collapsible panels). The parent component controls the state of which panels are open, while the child panels should be able to read the context to determine whether or not they're open.
const Accordion = ({ children }) => {
const [openSections, setOpenSections] = useState({})
const isOpen = sectionId => Boolean(openSections[sectionId])
const onToggle = sectionId => () =>
setOpenSections({ ...openSections, [sectionId]: !openSections[sectionId] })
const context = useMemo(() => createContext(), [])
// Can't tell children to use *this* context
return (
<context.Provider value={useMemo(() => ({ isOpen, onToggle }), [isOpen, onToggle])}>
{children}
</context.Provider>
)
}
const AccordionSection = ({ sectionId, title, children }) => {
const { isOpen, onToggle } = useContext(context)
// No way to infer the right context
return (
<>
<button onClick={onToggle(sectionId)}>{isOpen(sectionId) ? 'Close' : 'Open'}</button>
{isOpen && children}
</>
)
}
The only way I could think of accomplishing this would be to have Accordion run an effect whenever children changes, then traverse children deeply and find AccordionSection components, while not recursing any nested Accordion components -- then cloneElement() and inject context as a prop to each AccordionSection.
This seems not only inefficient, but I'm not even entirely sure it will work. It depends on children being fully hydrated when the effect runs, which I'm not sure if that happens, and it also requires that Accordion's renderer gets called whenever deep children change, which I'm not sure of either.
My current method is to create a custom hook for the developer implementing the Accordion. The hook returns a function which returns the isOpen and onToggle functions which have to manually be passed to each rendered AccordionSection. It works and is possibly more elegant than the children solution, but requires more overhead as the developer needs to use a hook just to maintain what would otherwise be state encapsulated in Accordion.
React.createContext will return an object that holds 2 components:
Provider
Consumer
These 2 components can share data, the Consumer can "grab" the context data from the nearest Provider up the tree (or use the useContext hook instead of rendering a Consumer).
You should create the context object outside the parent component and use it to render a Consumer inside your children components (or use the useContext hook).
Simple example:
const myContext = createContext();
const Accordion = ({ children }) => {
// ...
return (
<myContext.Provider value={...} >
{children}
</myContext.Provider>
)
}
const AccordionSection = (...) => {
const contextData = useContext(myContext);
// use the data of your context as you wish
// ...
}
Note that i used the useContext hook instead of rendering the Consumer, its up to you if you want to use the hook or the Consumer.
You can see more examples and get more details from the docs

Get element position in the DOM on React DnD drop?

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' ]
}
}

Categories