EDIT

Sorry for showing wrong use-case. All inputs inside the Form are being passed though this.props.children, and they can be situated at any deep point of the components tree, so the approach of passing the handleChange directly to inputs will not work at all.


Here is code snippet with the reproduction of the problem.

class CustomSelect extends React.Component {
  items = [
    { id: 1, text: "Kappa 1" },
    { id: 2, text: "Kappa 2" },
    { id: 3, text: "Kappa 3" }
  ]
  
  state = {
    selected: null,
  }
  
  handleSelect = (item) => {
    this.setState({ selected: item })
  }
  
  render() {
    var { selected } = this.state
    return (
      <div className="custom-select">
        <input
          name={this.props.name}
          required
          style={{ display: "none" }} // or type="hidden", whatever
          value={selected
            ? selected.id
            : ""
          }
          onChange={() => {}}
        />
        <div>Selected: {selected ? selected.text : "nothing"}</div>
        {this.items.map(item => {
          return (
            <button 
              key={item.id}
              type="button" 
              onClick={() => this.handleSelect(item)}
            >
              {item.text}
            </button>
          )
        })}
      </div>
    )
  }
}

class Form extends React.Component {
  handleChange = (event) => {
    console.log("Form onChange")
  }
  
  render() {
    return (
      <form onChange={this.handleChange}>
        {this.props.children}
      </form>
    )
  }
}

ReactDOM.render(
  <Form>
    <label>This input will trigger form's onChange event</label>
    <input />
    <CustomSelect name="kappa" />
  </Form>,
  document.getElementById("__root")
 )
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>


<div id="__root"></div>

As you can see, when you type something in default input (controlled or uncontrolled, whatever), form catches bubbled onChange event. But when you are setting the value of the input programmatically (with the state, in this case), the onChange event is not being triggered, so I cannot catch this changes inside the form's onChange.

Is there any options to beat this problem? I tried to input.dispatchEvent(new Event("change", { bubbles: true })) immediately after setState({ selected: input }) and inside it's callback, but there is no result.

3 Answers

1
nebuler On Best Solutions

Update your CustomSelect component with the following:

class CustomSelect extends React.Component {

    ...

    // you'll use this reference to access the html input.
    ref = React.createRef();

    handleSelect = item => {
        this.setState({ selected: item });

        // React overrides input value setter, but you can call the
        // function directly on the input as context
        const inputValSetter = Object.getOwnPropertyDescriptor(
            window.HTMLInputElement.prototype,
            "value"
        ).set;
        inputValSetter.call(this.ref.current, "dummy");

        // fire event
        const ev = new Event("input", { bubbles: true });
        this.ref.current.dispatchEvent(ev);
    };

    ...

    render() {
        ...

        return (
            <div className="custom-select">
                <input
                    // you'll use the reference in `handleSelect`
                    ref={this.ref}
                    name={this.props.name}
                    required
                    style={{ display: "none" }} // or type="hidden", whatever
                    value={selected ? selected.id : ""}
                    onChange={() => {}}
                />

                ...

            </div>
        );
    }

    ...
}

And your Form component with the following:

class Form extends React.Component {

    handleChange = event => {
        console.log("Form onChange");

        // remove synthetic event from pool
        event.persist();
    };

    ...
}
3
EQuimper On

I really think the best to do what you try to do is first make sure to control each individual input. Keep those values in state and just working with the onSubmit event from the form. React even recommended this approach here https://reactjs.org/docs/uncontrolled-components.html

In most cases, we recommend using controlled components to implement forms. In a controlled component, form data is handled by a React component. The alternative is uncontrolled components, where form data is handled by the DOM itself.

You can read about controlled here https://reactjs.org/docs/forms.html#controlled-components

If you want to see how I will have made with just control this will have been looks like that https://codesandbox.io/s/2w9qnk8lxp You can see if you click enter the form submit event with the value keep in state.

class CustomSelect extends React.Component {
  items = [
    { id: 1, text: "Kappa 1" },
    { id: 2, text: "Kappa 2" },
    { id: 3, text: "Kappa 3" }
  ];

  render() {
    return (
      <div className="custom-select">
        <div>
          Selected: {this.props.selected ? this.props.selected.text : "nothing"}
        </div>
        {this.items.map(item => {
          return (
            <button
              key={item.id}
              type="button"
              onClick={() => this.props.onChange(item)}
            >
              {item.text}
            </button>
          );
        })}
      </div>
    );
  }
}

class Form extends React.Component {
  state = {
    firstInput: "",
    selected: null
  };

  handleSubmit = event => {
    event.preventDefault();
    console.log("Form submit", this.state);
  };

  handleInputChange = name => event => {
    this.setState({ [name]: event.target.value });
  };

  handleSelectedChanged = selected => {
    this.setState({ selected });
  };

  render() {
    console.log(this.state);
    return (
      <form onSubmit={this.handleSubmit}>
        <label>This input will trigger form's onChange event</label>
        <input
          value={this.state.firstInput}
          onChange={this.handleInputChange("firstInput")}
        />
        <CustomSelect
          name="kappa"
          selected={this.state.selected}
          onChange={this.handleSelectedChanged}
        />
      </form>
    );
  }
}

But if you really want your way, you should pass down the handleChange function as a callback to the children and make use of this props as a function when you click on an element. Example here https://codesandbox.io/s/0o8545mn1p.

class CustomSelect extends React.Component {
  items = [
    { id: 1, text: "Kappa 1" },
    { id: 2, text: "Kappa 2" },
    { id: 3, text: "Kappa 3" }
  ];

  state = {
    selected: null
  };

  handleSelect = item => {
    this.setState({ selected: item });

    this.props.onChange({ selected: item });
  };

  render() {
    var { selected } = this.state;
    return (
      <div className="custom-select">
        <input
          name={this.props.name}
          required
          style={{ display: "none" }} // or type="hidden", whatever
          value={selected ? selected.id : ""}
          onChange={() => {}}
        />
        <div>Selected: {selected ? selected.text : "nothing"}</div>
        {this.items.map(item => {
          return (
            <button
              key={item.id}
              type="button"
              onClick={() => this.handleSelect(item)}
            >
              {item.text}
            </button>
          );
        })}
      </div>
    );
  }
}

class Form extends React.Component {
  handleChange = event => {
    console.log("Form onChange");
  };

  render() {
    return (
      <form onChange={this.handleChange}>
        <label>This input will trigger form's onChange event</label>
        <input />
        <CustomSelect name="kappa" onChange={this.handleChange} />
      </form>
    );
  }
}
3
Deckerz On

If you pass down the function from the form you can trigger it manually. You just need to create the new Event() to suite you needs of info. Since its a prop it will sync if any method changes happen in the parent element.

Since you use props to generate elements within the form you must map them like so. This was the event only gets added to the custom elements.

class CustomSelect extends React.Component {
  propTypes: {
        onChange: React.PropTypes.func
    }
  items = [
    { id: 1, text: "Kappa 1" },
    { id: 2, text: "Kappa 2" },
    { id: 3, text: "Kappa 3" }
  ]
  
  state = {
    selected: null,
  }
  
  handleSelect = (item) => {
    this.setState({ selected: item });
    this.props.onChange.self(new Event('onchange'))
  };
  
  render() {
    var { selected } = this.state
    return (
      <div className="custom-select">
        <input
          name={this.props.name}
          required
          style={{ display: "none" }} // or type="hidden", whatever
          value={selected
            ? selected.id
            : ""
          }
          onChange={() => {}}
        />
        <div>Selected: {selected ? selected.text : "nothing"}</div>
        {this.items.map(item => {
          return (
            <button 
              key={item.id}
              type="button" 
              onClick={() => this.handleSelect(item)}
            >
              {item.text}
            </button>
          )
        })}
      </div>
    )
  }
}

class Form extends React.Component {
  handleChange = (event) => {
    console.log("Form onChange")
  }
  
  render() {
    let self = this.handleChange;
    let children = React.Children.map(this.props.children, (child, i) => {
          if(typeof child.type === "function"){
            return React.cloneElement(child, {
              onChange: {self}
            });
          }
          return child;
        });
    return (
      <form onChange={this.handleChange}>
        {children}
      </form>
    )
  }
}

ReactDOM.render(
  <Form>
    <label>This input will trigger form's onChange event</label>
    <input />
    <CustomSelect name="kappa" />
  </Form>,
  document.getElementById("__root")
 )
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>


<div id="__root"></div>