1
\$\begingroup\$

I found an interesting question on Stack Overflow. It involves having two main checkboxes and 4 checkboxes for the 2 main checkboxes group.

I'm new to React and wanted to try this with React. I have two groups of checkboxes, one which shows a list of fruits and another which shows a list of vegetables.

The structure looks like this:

- fruits
- - Apple
- - Orange
- - Pineapple
- - Mango
- vegetables
- - Tomatoes
- - Cucumbers
- - Carrots
- - Avocados

Checking the fruits checkbox will check all of the fruit checkboxes. Unchecking one of the fruits, will remove the fruits checkbox from being checked. Same goes for vegetables.

As you can see in the fiddle, this is working but it involved a lot of hacks. How can this be refactored if at all?

jsFIddle

var ItemTree = React.createClass({
    getDefaultProps: function() {
        return {
            list: {},
        };
    },

    getInitialState: function() {
        var checklist = [];

        $.each(this.props.list, function(groupName, itemList) {
            var items = $.map(itemList, function(name) {
                return {
                    name: name,
                    checked: false,
                };
            });
            checklist.push({
                name: groupName,
                children: items,
                checked: false,
            });
        });

        return {
            checklist: checklist,
        };
    },

This code checks the main checkboxes and updates the children:

    handleCheckGroup: function(index) {
        var checklist = this.state.checklist;
        checklist[index].checked = !checklist[index].checked;
        var children = checklist[index].children;

Checking the child checkboxes and updating the state:

    $.each(children, function(i) {
        checklist[index].children[i].checked = true;
    })

        this.setState({
            checklist: checklist,
        })
    },

For when you click on the child checkboxes:

  checkChild: function(name, c, index) {
      var checklist = this.state.checklist;

I'm trying to find the checkbox group that contains the name of my checkbox.

      var groups = checklist.filter(function(group) {
          return group.children.some(function(child) {
              return child.name == name;
          });
      });
       var isChecked = groups[0].children[c].checked;

This toggles the checkbox on and off:

       if (!isChecked) {
        groups[0].children[c].checked = true;
      }
      else {
        groups[0].children[c].checked = false;
      }

This counts the number of checkboxes that are checked:

      var countChecked = groups[0].children.filter(function(child) {
          return child.checked;
      }).length;

If there are 4 checked, the main checkbox will automatically be checked:

      groups[0].checked = countChecked == 4;

      this.setState({
         checklist: checklist,
      })


      groups[0].children[index].checked = true;
  },

    render: function() {
        return (
            <div>
            {$.map(this.state.checklist, function(item, itemIndex) {
                return (
                    <div key={item.name}> 
                        {/* checking the main checkbox should automatically check all of the sub-items */}
                        <label>
                            <input type="checkbox" checked={item.checked} onChange={this.handleCheckGroup.bind(null, itemIndex)} /> <strong>{item.name}</strong>
                        </label>

                        <div style={{marginLeft: 20}}>
                        {$.map(item.children, function(childItem, childIndex) {
                            return (
                                <div key={childItem.name}>
                                    {/* if at least one of the sub-items are unchecked then the main/group checkbox should also be unchecked automatically */} 
                                    <label>
                                    <input type="checkbox" checked={childItem.checked} onChange={this.checkChild.bind(null, childItem.name, childIndex)} /> 
                                    {' '}
                                    {childItem.name}
                                    </label>
                                </div>
                            );
                        }.bind(this))}
                        </div>
                    </div>
                );
            }.bind(this))}
            </div>
        );
    }
});


jQuery(function($) {
    var itemList = {
        Fruits: ['Apple', 'Orange', 'Pineapple', 'Mango'],
        Vegetables: ['Tomatoes', 'Cucumbers', 'Carrots', 'Avocados']
    };
    ReactDOM.render(<ItemTree list={itemList} />, $('#component').get(0));
});

The problem is I'm thinking of this in a JavaScript way, probably not in a React way.

\$\endgroup\$
0

1 Answer 1

2
\$\begingroup\$

I did some refactoring and debugging (at least from my perspective) here. I also switched from jquery to lodash for utility functions.

Bug fixes:

  • If you check all the items in a group, the group becomes checked.
  • If you uncheck the group, all items become unchecked.
  • No longer mutating the component state. Instead it is cloned.
  • Items in two different groups can have the same name.

jsFiddle

JavaScript

class NestedCheckboxTree extends React.Component {

ES6 class syntax is cleaner, and you're transpiling for JSX anyway. I updated the name to be a little more descriptive. Note that you need to bind the event handlers yourself, so it's a good idea to do it in the constructor:

    constructor(props) {
        super(props);
        this.onGroupChange = this.onGroupChange.bind(this);
        this.onChildChange = this.onChildChange.bind(this);

        this.state = _.transform(this.props.list, (state, children, groupName) => {
            state[groupName] = {
                name: groupName,
                checked: false,
                children: _.transform(children, (group, child) => group[child] = { name: child, checked: false }, {})
            };
        }, {});
    }

I built a state object with no arrays, so the change handlers can find things without searching or needing indexes. That cleans up a lot. Also note that I discarded the extra object "checklist" in the state and put the groups there directly. No need for the extra nesting.

    onGroupChange(groupName) {
        let newState = _.cloneDeep(this.state);

        let group = newState[groupName];
        group.checked = !group.checked;
        _.forEach(group.children, c => { c.checked = group.checked });

        this.setState(newState);
    }

You're not supposed to mutate the state object, so this I clone it first. If you want to use the React immutability helpers you can and it might perform a little better. If you didn't want to uncheck the items when you uncheck the group you can make the forEach conditional here, but for my intended use case I do.

    onChildChange(groupName, childName) {
        let newState = _.cloneDeep(this.state);

        var group = newState[groupName];
        group.children[childName].checked = !group.children[childName].checked;
        group.checked = _.every(group.children, "checked");

        this.setState(newState);
    }

This takes both the group name and the item name so we can update things without any searching and allowing different groups to contain children with the same name. Again, if you don't consider it a feature that checking all the items also checks the group, you could do something like group.checked = group.checked && group.children[childName].checked;

    render() {
        return (
            <div>
                {_.map(this.state, (item) =>
                    <CheckboxGroup key={item.name} onGroupChange={this.onGroupChange} onItemChange={this.onChildChange} {...item} />
                )}
            </div>
        );
    }
}

Splitting out a CheckboxGroup sub-component keeps the render small and readable.

function CheckboxGroup(props) {
    return (
        <div>
            <label>
                <input type="checkbox" checked={props.checked} onChange={props.onGroupChange.bind(null, props.name)} /> <strong>{props.name}</strong>
            </label>

            <div style={{marginLeft: 20}}>
                {_.map(props.children, function (childItem) {
                    return (
                        <Checkbox key={childItem.name} onChange={props.onItemChange.bind(null, props.name)} {...childItem} />
                    );
                }.bind(this))}
            </div>
        </div>
    );
}

Stateless functional components are cleaner and more reusable. But in truth this one is still much too ugly. Checkbox should accept children in the props so that the group checkbox would just be another Checkbox and you could still put the <strong> in there. And of course the divs should have classes so you could style them in CSS. Note that I'm binding one parameter of the onItemChange method here before passing it down to the Checkbox. This means the Checkbox doesn't need to know what group it is in.

function Checkbox(props) {
    return (
        <div>
            <label>
                <input type="checkbox" checked={props.checked} onChange={props.onChange.bind(null, props.name)} />
                {' '}
                {props.name}
            </label>
        </div>
    );
}

This is more like what a functional component should look like. Bind the onChange method with another argument, so the NestedCheckboxTree event handler gets both.

var itemList = {
    Fruits: ['Apple', 'Orange', 'Pineapple', 'Mango'],
    Vegetables: ['Tomatoes', 'Cucumbers', 'Carrots', 'Avocados']
};

ReactDOM.render(<NestedCheckboxTree list={itemList} />, document.getElementById('component'));

Just removed the jquery here so I could remove that dependency entirely.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.