How do I manage UI state among subcomponents? - reactjs

I want to display a selectable list of objects with react. I've got the list component, which owns several groups, which own several elements. Obviously, the list data itself should go into a store, but what about the UI state (e.g., which element is selected)? It's ostensively a property of the root list element, but to pass it down to the elements, each element along the chain (well, just 1 in this case- the group) would need to pass it along even if they don't care about it. And if I want to add another property I need to pass it down the chain rather verbosely.
Also, encapsulation. How do I make a component that might be instanced multiple times with different data?
In other languages I would just pass the list pointer down the chain, so that child elements could whatever they want from it, so everything stays encapsulated. Would the flux way be to pass the store down to child elements? Would you have a separate store for UI state vs the persistent data?

Let's design this from the bottom up. So, at the bottom we have a ListItem.
What does the list item need in order to render? Let's say it's a title, a body, and if it's selected or not.
Also what events make sense of the list item? Probably just onClick.
Does it have any state? No, unless we need to handle things like it being hovered, or other state specific to the ListItem, but not relevant to its parent.
var ListItem = React.createClass({
render(){
var classNames = ["list-item"];
if (this.props.selected) classNames.push("selected");
return (
<li className={classNames.join} onClick={this.props.onClick}>
<div className="title">{this.props.title}</div>
<div className="body">{this.props.body}</div>
</li>
);
}
});
The ListGroup is a bit less obvious.
For props we'll ask for a list of items, the selected index (if any), and an onSelectionChange callback, which is called with (nextIndex, lastIndex).
This component also has no state. For extra points, we'll allow specifying a custom renderer, making this more reusable. You can pass renderer={component} where component is something that implements the ListItem interface described above.
var ListGroup = React.createClass({
getDefaultProps: function(){
return {renderer: ListItem, onSelectionChange: function(){}}
},
render(){
return (
<div>{this.props.items.map(this.renderItem)}</div>
);
},
renderItem(data, index){
var selectedIndex = this.props.selectedIndex;
return (
<this.props.renderer
selected={selectedIndex === index}
key={i}
onClick={() => this.props.onSelectionChange(index, selectedIndex)}
{...data} />
}
});
And then we could render ListGroup like this:
<ListGroup
items={[{title: "foo", body: "bar"}, {title: "baz", body: "quux"]}
selectedIndex={this.state.i}
onSelectionChange={(index) => this.setState({i: index})} />
The data is passed down the tree, but only the essence of the data required to render each component. ListItem doesn't need the full list of items, nor the selected index. It doesn't care if multiple selections are allowed, or just one. All it knows is that it's selected when this.props.selected is truthy, and otherwise it's not.

Related

React.js re-render behavior changes when list contents change

This question requires some background to understand:
My app uses various lists of items with Boolean state that the user toggles by clicking.
I implemented this logic with two reusable elements:
A custom hook, useSelections(), which maintains an array of objects of the form { id, name, isSelected }, where isSelected is Boolean and the only mutable element. It returns the array as current state, and a dispatch function that takes an input of the form { id, newValue } and updates the isSelected member of the object with the given id
A function component Selector, which takes as props a single item and the dispatch from useSelections and returns a <li> element whose CSS class depends on isSelected.
Normally, they are used together in the following way, which works fine (i.e., internal state and the color of the list item are synchronized, and toggle when clicked):
function localComponent(props) {
const [items, dispatch] = useSelections(props.data);
return (
<ul>
{items.map(item => <Selector key={item.id} item={item} onChange={dispatch} />)}
</ul>
);
}
It works equally well when useSelections() is elevated to a parent component, and items and dispatch are passed as props.
The trouble started when the array became larger, and I decided to page it:
<ul>
{items.slice(start, end).map(item => <Selector key={item.id} item={item} onChange={dispatch} />)}
</ul>
(start and end are part of component state.)
When my component is first rendered, it works normally. However, once start and end are changed (to move to the next page), clicking an item changes internal state, but no longer triggers a re-render. In UX terms this means that after the first 'next' click, when I click on an item nothing appears to happen, but if I hit 'next' again, and then 'back', the item I just clicked on has now changed.
Any suggestions would be appreciated.
I solved the problem by removing the logic from the reducer function to ignore calls where there is no change:
const hasChanged = modifiedItem.isSelected !== newValue;
modifiedItem.isSelected = newValue;
return hasChanged ? { data } : state;
is now simply:
modifiedItem.isSelected = newValue;
return { data };
There's still something going on with React here that I do not understand, but my immediate problem is solved.

How do I make children's props update when parent state changes for children initialized in a map function?

I have a bunch of child components being initialized in a map function in ComponentDidMount:
this.mappedApplications = this.props.applications.map((application, key) =>
<ApplicationCard
selectedYear={this.state.openApplicationYear}
applicationYear={application.application_year}
handleArrowClick={this.handleApplicationCardArrowClick}
key={key}
/>, this
);
this.setState({
applications: this.mappedApplications,
});
When the state.openApplicationYear changes I want it to change for all of the Application Cards. I do a check in the application card show info if the selected year matches the application year, with the goal that only one card will be open at a time. The cards have dropdown arrows that allow the user to open or close them. Right now the parent's openApplicationYear is changing correctly, but the children's aren't updating.
Here is the render function if it helps:
render() {
return (
<div className="dashboard__card">
<h3 className="mb-3">Applications</h3>
{this.state.applications}
</div>
)
}
I have bound "this" to the map function as seen above. Is there anything else I need to do to update the children when the parent's state changes?

Can React.createRef() be created dynamically from an array of state objects?

I have a list of styled radio buttons fixed inside the sidebar that, when clicked, scroll a corresponding component from the main area into view.
Both the radio buttons list and the components inside the main area are mapped from the state array called usedComponents that contains objects of component properties and ids. Let's say the array contains 10 objects and each object looks something like this:
{
id: 1,
group: "header",
componentType: "headerLogo",
componentName: "Header 01",
//...rest of the component properties
}
In order to achieve scroll into view on radio button click I had to create references to components inside the constructor. I manually created references for each component contained inside usedComponents state array and tied them to componentType like this:
this.headerLogo = React.createRef();
this.headerLogoNavigation = React.createRef();
//...etc.
I then passed the reference to each corresponding component and in my selectComponentHandler I set scrollIntoView to call the radio button id which corresponds to the main area component reference.
The selectComponentHandler looks like this:
selectComponentHandler = e => {
const value = Number(e.target.value);
const id = e.target.id; //returns componentType. For example "headerLogo"
this.setState(prevState => {
let selected = prevState.usedComponents
.filter(item => item.id === value)
.shift();
let unSelected = prevState.usedComponents
.filter(item => item.selected === true)
.shift();
if (unSelected) {
unSelected.selected = false;
}
selected.selected = true;
return { unSelected, selected };
});
this[id].current.scrollIntoView({ block: "start", behavior: "smooth" });
};
However, I'm using react-beautiful-dnd in order to be able to add new components by dragging them to the main area, and whenever I add the new component of the type that is already contained inside the usedComponents array, it has the same reference as the old component of the same type. This makes the scrollIntoView always scroll to the component of the same type that is first in line, no matter which one I select.
Is there a way to create references dynamically for all the components inside the usedComponents array, by perhaps mapping them, and update them whenever a new component is inserted. Maybe tie them to ids?
Edit:
I tried mapping references inside the constructor like this:
this.state.usedComponents.map(component => {
const id = component.id.toString();
return (this[id] = React.createRef());
});
It works for the components that are already inside the used Components array, however I still don't know how to update the references when the new object is inserted into the usedComponents array via drag and drop. Newly inserted components basically don't have references.
Like I was mentioning in the comments, I wouldn't go the route of dynamic refs. The scope / size of these refs could grow large and there is a much better way to handle this. Just use the DOM.
When rendering you can just put the components ID on your html element that you want to scroll to
<div id={component.id}> ... </div>
And then when you want to scroll to that element just query the DOM for that element and scroll to it
const elemToScrollTo = document.getElementById(component.id)
if (!!elemToScrollTo) {
elemToScrollTo.scrollIntoView()
}

React design question about creating remove functionality

This is a little long winded.
I would like advice on how to go about making the functionality of a remove button. I'm relatively new to react and should have planned this project before I embarked. Either way I am here now and looking for some advice before I remove functionality tomorrow.
In this component I create a method called "displayFood" in which I take an array from props that has in it string values of the names of foods that the user wanted to add to the refrigerator. For example: [yogurt, milk, egg, yogurt, yogurt]. I then create an object that maps this array to key value pairs based on name and quantity, for example: {"yogurt": 3, "milk": 1, "egg": 1}. After this I create an array that holds what I want to render to the user which is each item that they put in the fridge and the quantity of that item. I also have a remove button. I have been thinking about how to remove Items but do not know how to go about doing so.
If, for example the user deletes yogurt I want the value to decrease by one, and if the user deletes a item with quantity 1 then it should go away.
This is a pretty specific question thank you for your time.
import React from 'react';
import "./style.scss";
function InFrige(props) {
const handleRemove = (e, counts) => {
console.log(e.target.name)
}
const displayFood = () => {
var counts = {};
props.foodAddedByUser.forEach(function(x) { counts[x] = (counts[x] || 0) + 1; });
let foodItems = []
for(var index in Object.entries(counts)){
foodItems.push(
<div className="inFrige-food-item" key={index}>
<h3>{Object.keys(counts)[index]} x{Object.values(counts)[index]}</h3>
<button onClick={handleRemove} name={Object.keys(counts)[index]}>Remove</button>
</div>
)
}
return foodItems
}
return (
<div>
<div className="inFrige-food-container">
{displayFood()}
</div>
</div>
)
}
export default InFrige;
Your problem is you are trying to alter your props from within the component. You could either handle this inside the component with state or give a callback via props from the parent component and handle the deletion there, something like this:
<button onClick={()=>{this.props.handleRemove(Object.keys(counts)[index])}} name={Object.keys(counts)[index]}>Remove</button>
in parents render:
<InFridge handleRemove={(item)=>{foodAddedByUser.delete(item)} foodAddedByUser={foodAddedByUser} />

How do I call an event handler or method in a child component from a parent?

I'm trying to implement something similar to the Floating Action Button (FAB) in the Material-UI docs:
https://material-ui.com/demos/buttons/#floating-action-buttons
They have something like:
<SwipeableViews>
<TabContainer dir={theme.direction}>Item One</TabContainer>
<TabContainer dir={theme.direction}>Item Two</TabContainer>
<TabContainer dir={theme.direction}>Item Three</TabContainer>
</SwipeableViews>
{
fabs.map((fab, index) => (
<Zoom>
<Fab>{fab.icon}</Fab>
</Zoom>
));
}
I have something like:
<SwipeableViews>
<TabContainer dir={theme.direction}>
<ListOfThingsComponent />
</TabContainer>
<TabContainer dir={theme.direction}>Item Two</TabContainer>
<TabContainer dir={theme.direction}>Item Three</TabContainer>
</SwipeableViews>
{
fabs.map((fab, index) => (
<Zoom>
<Fab onClick={ListOfThingsComponent.Add???}>
Add Item to List Component
</Fab>
</Zoom>
));
}
My ListOfThingsComponent originally had an Add button and it worked great. But I wanted to follow the FAB approach for it like they had in the docs. In order to do this, the Add button would then reside outside of the child component. So how do I get a button from the parent to call the Add method of the child component?
I'm not sure how to actually implement the Add Item to List click event handler given that my list component is inside the tab, while the FAB is outside the whole tab structure.
As far as I know I can either:
find a way to connect parent/child to pass the event handler through the levels (e.g. How to pass an event handler to a child component in React)
find a way to better compose components/hierarchy to put the responsibility at the right level (e.g. remove the component and put it in the same file with this in scope using function components?)
I've seen people use ref but that just feels hacky. I'd like to know how it should be done in React. It would be nice if the example went just a bit further and showed where the event handling should reside for the FABs.
thanks in advance, as always, I'll post what I end up doing
It depends on what you expect the clicks to do. Will they only change the state of the given item or will they perform changes outside of that hierarchy? Will a fab be present in every single Tab or you're not sure?
I would think in most cases you're better off doing what you were doing before. Write a CustomComponent for each Tab and have it handle the FAB by itself. The only case in which this could be a bad approach is if you know beforehand that the FAB's callback will make changes up and out of the CustomComponent hierarchy, because in that case you may end up with a callback mess in the long run (still, nothing that global state management couldn't fix).
Edit after your edit: Having a button call a function that is inside a child component is arguably impossible to do in React (without resorting to Refs or other mechanisms that avoid React entirely) because of its one-way data flow. That function has to be somewhere in common, in this case in the component that mounts the button and the ListOfThings component. The button would call that method which would change the state in the "Parent" component, and the new state gets passed to the ListOfThings component via props:
export default class Parent extends Component {
state = {
list: []
};
clickHandler = () => {
// Update state however you need
this.setState({
list: [...this.state.list, 'newItem']
});
}
render() {
return (
<div>
<SwipeableViews>
<TabContainer dir={theme.direction}>
<ListOfThingsComponent list={this.state.list /* Passing the state as prop */}/>
</TabContainer>
<TabContainer dir={theme.direction}>Item Two</TabContainer>
<TabContainer dir={theme.direction}>Item Three</TabContainer>
</SwipeableViews>
{
fabs.map((fab, index) => (
<Zoom>
<Fab onClick={this.clickHandler /* Passing the click callback */}>
Add Item to List Component
</Fab>
</Zoom>
))
}
</div>
)
}
}
If you truly need your hierarchy to stay like that, you have to use this method or some form of global state management that the ListOfThingsComponent can read from.

Resources