React Parent-Child Relationship and Encapsulation - reactjs

I have a React design problem that I am trying to solve, and I am running into an issue counter-intuitive to the idea of encapsulation as I try to breakdown an existing component into parent-child to support an additional use case. I am using React 15.3.2 with Redux, ES6, and ImmutableJS. First, I will illustrate the design problem, then I will provide snippets to illustrate why I feel that I have the need to get data back from children and how that is good for encapsulation, and the roadblock I am hitting.
I have read this stackoverflow which has detailed explanation on why passing data from children to parent component is not a good idea,
Pass props to parent component in React.js
But I have some concerns, which I will discuss at the end.
Design:
I am starting with a CheckboxSelect component. The Title bar's text depends on the checked items.
Closed:
Open with selections (current implementation):
To support additional use-case, the dropdown will now open up with more stuff.
Open with selections (new update):
Initial Code:
I am starting with a CheckboxSelect controlled component with the following props interface:
CheckboxSelect.jsx:
CheckboxSelect.propTypes = {
// what title to display by default with no selection
defaultTitle: PropTypes.string.isRequired, ie. "Selected Scopes"
// array of selected options, ie. [{key: "comedy", name: "comedy", checked: false }, ...]
options: PropTypes.array.isRequired,
// handler for when the user checks a selection, this will update
// the reducer state, which causes the options prop to be refreshed and
// passed in from the outer container
onCheck: PropTypes.func.isRequired,
onUncheck: PropTypes.func.isRequired,
onCheckAll: PropTypes.func,
onUncheckAll: PropTypes.func,
className: PropTypes.string,
// controls the open state of the dropdown
open: PropTypes.bool,
// handler for when the user clicks on the dropdown button, this will update the reducer state,
// which causes the open prop to be refreshed and passed in from the outer container
onClick: PropTypes.func,
onCancel: PropTypes.func,
};
// there is currently some logic inside the component to generate the title to display
// produces "comedy, action"
getTitle() {
const { defaultTitle } = this.props;
const checked = this.getChecked();
let fullTitle;
if (this.allChecked() || this.allUnchecked()) {
fullTitle = `${defaultTitle } (${checked.length})`;
} else {
fullTitle = checked.map((option) => option.name).join(', ');
}
return fullTitle;
}
getChecked() {
const { options } = this.props;
return options.filter(option => option.checked);
}
allChecked() {
const { options } = this.props;
return this.getChecked().length === options.length;
}
allUnchecked() {
return this.getChecked().length === 0;
}
ApplicationContainer.jsx (where the component is being used):
scopeDropDownOptions = (currentMovie) => {
// returns [{key: "comedy", name: "comedy", checked: false }]
const applicableScopes = currentMovie.getIn(['attributes', 'applicable_scopes']);
return currentMovie.getIn(['attributes', 'available_scopes']).reduce((result, scope) => {
result.push({
key: scope,
name: scope,
checked: (applicableScopes.includes(scope)),
});
return result;
}, []);
}
onSelectScope = (scope) => {
const movieScopes = this.applicableScopes.push(scope.key);
this.onUpdateField('applicable_scopes', movieScopes);
}
render() {
...
<CheckboxSelect
defaultTitle="Selected Scopes"
options={this.scopeDropdownOptions(currentMovie)}
onCheck={this.onSelectScope}
onUncheck={this.onDeselectScope}
onCheckAll={this.onSelectAllScopes}
onUncheckAll={this.onDeselectAllScopes}
open={store.get('scopeDropdownOpen')}
</CheckboxSelect>
}
New Code:
To support the new layout, I would like to break the existing component into two: a DynamicDropdown that contains CheckboxSelect2 as one of the children, along with any other elements that may be dropped down. Here is how the new code will look inside the ApplicationContainer.
ApplicationContainer.jsx
scopeDropDownOptions = (currentMovie) => {
// returns [{key: "comedy", name: "comedy", checked: false }]
const applicableScopes = currentMovie.getIn(['attributes', 'applicable_scopes']);
return currentMovie.getIn(['attributes', 'available_scopes']).reduce((result, scope) => {
result.push({
key: scope,
name: scope,
checked: (applicableScopes.includes(scope)),
});
return result;
}, []);
}
onSelectScope = (scope) => {
const {store } = this.props;
const cachedApplicableScopes = store.get('cachedApplicableScopes').push(scope.key);
store.get('cachedApplicableScopes').push(scope.key);
this.actions.setCachedApplicableScopes(cachedApplicableScopes);
// wait until apply is clicked before update
}
render() {
...
<DynamicDropdown
className="scope-select"
title={this.scopeDropdownTitle()}
open={store.get('scopeDropdownOpen')}
onClick={this.onScopeDropdownClick}
onCancel={this.onScopeDropdownCancel}
>
<CheckboxSelect2
options={this.scopeDropdownOptions(currentMovie)}
onCheck={this.onSelectScope}
onUncheck={this.onDeselectScope}
onCheckAll={this.onSelectAllScopes}
onUncheckAll={this.onDeselectAllScopes}
visble={store.get('scopeDropdownOpen')}
/>
// ... other children like confirmation message and buttons
</DynamicDropdown>
}
// logic related to CheckboxSelect2 title generation moved to the ApplicationContainer. Not ideal in my opinion as it breaks encapsulation. Further discussions in the Choices section
getScopesChecked() {
const options = this.scopeDropdownOptions(this.currentMovie);
return options.filter(option => option.checked);
}
scopesAllChecked() {
const options = this.scopeDropdownOptions(this.currentMovie);
return this.getScopesChecked().length === options.length;
}
scopesAllUnchecked() {
return this.getScopesChecked().length === 0;
}
scopeDropdownTitle() {
const defaultTitle = "Selected Scopes";
const checked = this.getScopesChecked();
let fullTitle;
if (this.scopesAllChecked() || this.scopesAllUnchecked()) {
fullTitle = `${defaultTitle} (${checked.length})`;
} else {
fullTitle = checked.map((option) => option.name).join(', ');
}
return fullTitle;
}
Problem:
The problem I have is with populating the title props of the DynamicDropdown element with the New Code, since it depends on the result of the CheckboxSelect2 selection.
Keep in mind CheckboxSelect2 is a dumb controlled component, we pass an onCheck handler to it. The this.onSelectScope inside the ApplicationContainer, is responsible for updating the reducer state of what has been checked, which in turn refresh the props and causes the DynamicDropdown and CheckboxSelect2 to be re-rendered.
Choices:
In the old code, there is a group of logic used to figure out the title to display for the dropdown. Here are the choices I am presented with:
To keep encapsulation of letting the CheckboxSelect2 summarize the
title, I tried initially keeping the same title logic inside
CheckboxSelect2, and accessing it via a ref.
ApplicationContainer.jsx
<DynamicDropdown
title={this.childCheckboxSelect.getTitle()}
>
<CheckboxSelect2
ref={(childCheckboxSelect) => this.childCheckboxSelect = childCheckboxSelect}
>
</DynamicDropdown>
At the time that DynamicDropdown is re-rendered, CheckboxSelect2
hasn't received the new props yet from the parent via the one-way
data-flow, so as a child, it actually cannot generate the most
up-to-date title for the DynamicDropdown based on what has been
checked. With this implementation, my title is one-step behind what
was actually checked.
As shown in the ApplicationContainer.jsx for the New Code section
above, the logic for the scopeDropdownTitle could be moved out from
CheckboxSelect2 to ApplicationContainer.jsx, so it sits a level
above DynamicDropdown, enabling the DynamicDropdown to get the
updated title from the reducer state as it renders. While this
solution works, it totally breaks my view on encapsulation, where
the responsibility for determining what title to be displayed, is
something that the CheckboxSelect2 should know about. Basically the
title logic in ApplicationContainer.jsx, now also pre-generates the
options props meant to passed to CheckboxSelect2 to render that
component, the CheckboxSelect2 logic is bleeding into the outer
container.
Let's look at the argument in the stackoverflow post Pass props to parent component in React.js and how it relates to this design problem and an analogy:
"The parent already has that child prop. ...  if the child has a
prop, then it is because its parent provided that prop to the
child!"  Sure, the ApplicationContainer has all the knowledge it
needs to generate the title for the parent DynamicDropdown based on
the checked states of the child CheckboxSelect2, but then is it
really the responsibility of the ApplicationContainer?
Let me give an analogy of a Manager asking an Employee to produce a Report. You can say, the Manager already has all the info, he can surely produce the Report himself! Having a controlled component where the
container manages update to the props for the child via callbacks, seems to me like a Manager passing a bucket to the Employee, the Employee passes back bits and pieces of information in the bucket, and tell the Manager to do the work to summarize things instead of the Employee producing a good summary (which is good encapsulation).
"Additionally, you could have used a ref directly on the child"
I think in the Choices 1) I stated above, using ref does not seem to work when you want up-to-date information from the child as
there is a circular dependency issue with one-way dataflow (ie.
parent needs to get up-to-date summary information from the child,
but the child first depends on the up-to-date information from the
parent).
If you have read this end-to-end and understood the problem, I appreciate your effort! Let me know what you think.

Related

Filtered list: how best to keep keyboard nav in sync with the list as rendered?

I'm building out keyboard navigation for a composable React combobox component. As it stands, it works except when the visible list of options is filtered down to match text field input. I'm using React.Children in the callback below to build an array corresponding to my options list; this works well on initialization, but after filtering (and re-rendering the options that match the filter) the result when I call it still includes all items, rendered or not, with the props they initially had. (I had the "bright idea" of giving unrendered children a tabindex of -2 and filtering that way, but this only works if the values are updated...)
Is there a better way than React.Children to populate my array of IDs and values/labels for my options, and to update it based on what was most recently rendered?
If so, where could I learn about it?
If not:
how can I access the current state, rather than a stale one?
what's the best way of pointing to the specific children in question? The method I'm using looks brittle.
const updateOptions = useCallback(
() =>
// set the optionStateMap from children
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) return;
if (!child.props.children) return;
const optionsArray: Array<optionStateProps> = [];
child.props.children[0].forEach( // will break if my list is not the first child with children; d'oh
(option: { props: { id: string; label: string, tabIndex: number} }) => {
if (option.props.tabIndex < -1) return;
optionsArray.push({
id: option.props.id,
label: option.props.label
});
}
);
setOptionStateMap(optionsArray);
}),
[children]
);
Thanks for looking!

react-jsonschema-form custom element position

Using react-jsonschema-form [1] we can dynamically render React components from configuration rather than code. I.e.
const Form = JSONSchemaForm.default;
const schema = {
title: "Test form",
type: "object",
properties: {
name: {
type: "string"
},
age: {
type: "number"
}
}
};
ReactDOM.render((
<Form schema={schema} />
), document.getElementById("app"));
And we can use uiSchema object to customize and configure the individual look of each component. Is it possible however, instead of having the form created in a linear fashion, can I obtain each individual element and position them where I want?
For example, I may want the name field on page1 and the age field on page3. The only way I can think to do this currently is editing the package's source to return individual elements rather than wrapping them into the Form object.
Maybe there is some way in React to interrogate and extract the individual components from the Form object?
Thanks
[1] https://react-jsonschema-form.readthedocs.io/en/latest/
No, there isn't a way to get rjsf to split a form into multiple UI components. You can emulate that behavior by making multiple forms (multiple JSON Schema schemas). Depending on the structure of the combined output you want, it may be simplest to break the schema down by different nested property apparent at the root level, e.g. the 1st page corresponds to the 1st key at the root level (name), the 2nd page corresponds to the 2nd key (age), and so on. Note that you'd have to control this pagination and navigation yourself, and also merge each page's formData back into the singular payload you were expecting.
Here's a rough draft:
pageOneSchema = {
title: "Test form pg. 1",
type: "object",
properties: {
name: {
type: "string"
}
}
};
pageTwoSchema = {
title: "Test form pg. 2",
type: "object",
properties: {
age: {
type: "number"
}
}
};
const schemas = [pageOneSchema, pageTwoSchema];
// Your component's member functions to orchestrate the form and its "pages"
state = {
currentPage: 0,
pages: new Array(schemas.length).fill({}), // initial pages array to empty objects = unfilled forms
}
pageDataChangeHandler = (page) => {
const onChange = (e) => {
const pages = [...this.state.pages];
pages[page] = e.formData; // not too 100% about the key
this.setState({
pages,
});
}
}
getSubmitHandlerForPage = (page) => {
if (page !== schemas.length) {
// If current page is not the last page, move to next page
this.setState({
currentPage: page + 1,
});
} else {
// concat all the pages' data together to recompose the singular payload
}
}
...
const { currentPage } = this.state;
<Form
formData={this.state.pages[currentPage]}
schema={schemas[currentPage]}
onChange={this.pageDataChangeHandler(currentPage)}
onSubmit={this.getSubmitHandlerForPage(currentPage)}
/>
You can also pass children into the Form component to render your own buttons (so maybe you don't want the forms saying "Submit" instead of "Next Page" if they're not actually going to submit).
Note that if you do custom buttons and make a "Next" button, it must still be of "submit" type because of the type of event it emits (used for validation and some other things). Though there was an ask to make the text/title of the submit button easier to manipulate...

Handling children state in an arbitrary JSON tree

This is more of a brainstorming question as I can't really seem to come up with a good solution.
I have a component which renders a tree based on some passed JSON (stored at the top level). Each node of the tree can have 0..n children and maps to a component defined by the JSON of that node (can be basically anything is the idea). The following is just an example and the names don't mean anything specific. Don't pay too much attention to the names and why a UserList might have children that could be anything.
JSON: {
data: {}
children: [
{
data: {}
children: []
},
{
data: {}
children: []
},
{
data: {}
children: [
{
data: {}
children: []
},
...etc
]
},
]
}
const findComponent = (props) => {
if (props.data.name === "userSelector") {
return <UserSelectorNode {...props}>;
} else if (props.data.name === "userInformation") {
return <UserInformationNode{...props}>; // example of what might be under a userSelectorNode
}
...etc
};
// render a user selector and children
const UserSelectorNode = (props) => {
const [selected, setSelected] = React.useState([])
// other methods which can update the JSON when selected changes...
return (
<div>
<UserSelector selected={selected}/> // does a getUser() server request internally
<div>
{props.data.children.map((child) => findComponent(child))}
<div>
</div>
);
};
This tree can be modified at any level (add/remove/edit). Adding/Editing is easy. The problem is remove operations.
Some children components use existing components which do things like getting a list of users and displaying them in a list (stored in state I have no access to). When a node on the tree is removed new components are made for every node that has to shift (JSON at the index is now different), which can be a lot. This causes a bunch of requests to occur again and sometimes state can be lost entirely (say the page number of a table to view users).
I'm pretty sure there is no way for the "new" UserSelector created when the JSON shifts to keep the same state, but I figured I may as well ask if anyone has dealt with anything similar and how they went about designing it.
The only way I can think of is to not actually reuse any components and re implement them with state stored somewhere else (which would suck), or rewrite everything to be able to take internal state as well as an external state storage if required.
EDIT: Added Sandbox
https://codesandbox.io/s/focused-thunder-neyxf. Threw it together pretty quick to only get a single layer of remove working which shows the problem.

How to change element of list after clicking on it

I'm new to reactJS, and to react-native. I need advice.
I'm having hard time with understanding how to create working action of element of a list.
So. I have parent component which creates tasks in scrollView.
<ScrollView>
{this.state.tasks.map(function(ele) {
return (
<Task
action={this.handler}
key={ele.id}
title={ele.title}
subtitle={ele.subtitle}
state={ele.state}
/>
);
})}
</ScrollView>
My parent component has state with variable tasks define in constructor.
this.state = {
tasks: [
{ title: "A1", subtitle: "xyz", state: 0, id: 0 },
{ title: "A2", subtitle: "zyx", state: 0, id: 1 }]
}
And I want to change state to 1 if specific Task is clicked. I've tried to achived by doing sth like here: https://ourcodeworld.com/articles/read/409/how-to-update-parent-state-from-child-component-in-react
But this is not working.
From what I have understood, you want to update the 'state' property in your tasks for particular task. In your handler, you could do something like this :
handler(ele) {
let updatedTasks = this.state.tasks.map((task)=> {
if(ele.id === task.id) {
return Object.assign({}, task, {
state: 1
});
} else {
return task;
}
})
//Set the new state
this.setState({
tasks: updatedTasks
})
}
In your ScrollView action should be :
action={(ele) => this.handler(ele)}
You didn't provide much context, but basically it should be enough to give the id of your specific task in the handler call, either by providing it at the lower Task component level, or binding it when you're creating the element (by doing something like:
action={this.handler.bind(this, ele.id)}
, after which calling handler() at the Task level already contains the correct id.
Then in your handler method you have the id at hand, and can then modify the proper task in your state. This is a bit annoying to do in a clean way - you have to take the old task lists and create a new list with the proper task modified. After that you can modify the state with this.setState(newTaskList).

Rerender child after parent's setState

So I have a Stage in my application which contains different sets of data for each.
What I want to do is if I change my stage, that my child component refreshes with data from said stage.
I have this stage selection on the top of every page, in my App.jsx component.
This stage is handled by the state through Redux
....
changeStage: function (stageId) {
this.props.setStage(this.getStageById(stageId));
},
....
var mapDispatchToProps = function (dispatch) {
return {
setStage: function (data) { dispatch(stageService.setStage(data)); }
}
};
module.exports = ReactRedux.connect(mapStateToProps, mapDispatchToProps)(App);
Now in my secondary (child - but deep down) component I have following:
shouldComponentUpdate: function (nextprops, nextstate) {
console.log("stage id in this component: " + this.state.stageId);
console.log("stage id in nextstate: " + nextstate.stageId);
console.log("stage id from the redux store: "+ this.props.stage.id)
return true;
},
....
var mapStateToProps = function (state) {
return {
stage: state.stage
};
}
but nextstate doesn't contain the updated stage but rather the previous stage, which makes sense.
The state.stageId also contains the previous - also still makes sense.
but the this.props.stage.id I expected to contain the newly selected stage but it doesn't.
It actually contains the previous stage so that means this gets called before the state is updated or is in process of updating the state.
So anyway my question, how can I get the new stage selected?
I do not want to pass the stage object down to all my children because that would make it not readable at all anymore and would be sloppy in my opinion.
Anyone have any idea?
The
You simply need to use nextprops.stage.id instead of this.props.stage.id to get the nextprops passed to your component.

Resources