NSjonas
NSjonas

Reputation: 12071

React/Redux controlled input with validation

Lets imagine we want an input for a "product" (stored in redux) price value.

I'm struggle to come up with the best way to handle input constraints. For simplicity, lets just focus on the constraint that product.price cannot be empty.

It seems like the 2 options are:


1: Controlled

Implementation: The input value is bound to product.price. On change dispatches the changePrice() action.

The main issue here is that if we want to prevent an empty price from entering the product store, we essentially block the user from clearing the input field. This isn't ideal as it makes it very hard to change the first digit of the number (you have to select it and replace it)!

2: Using defaultValue

Implementation: We set the price initially using input defaultValue, that allows us to control when we want to actually dispatch changePrice() actions and we can do validation handling in the onChange handler.

This works well, unless the product.price is ever updated from somewhere other than the input change event (for example, an applyDiscount action). Since defaultValue doesn't cause rerenders, the product.price and the input are now out of sync!


So what am I missing?

There must be a simple & elegant solution to this problem but I just can't seem to find it!

Upvotes: 1

Views: 3536

Answers (3)

Sagiv b.g
Sagiv b.g

Reputation: 31024

What i would do in this case is to validate the input onBlur instead of onChange.

For example consider these validations in the flowing snippet:

  1. The input can't be empty.
  2. The input should not contain "foo".

class App extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      myVal: '',
      error: ''
    }
  }

  setError = error => {
    this.setState({ error });
  }

  onChange = ({ target: { value } }) => {
    this.setState({ myVal: value })
  }

  validateInput = ({ target: { value } }) => {
    let nextError = '';
    if (!value.trim() || value.length < 1) {
      nextError = ("Input cannot be empty!")
    } else if (~value.indexOf("foo")) {
      nextError = ('foo is not alowed!');
    }

    this.setError(nextError);
  }

  render() {
    const { myVal, error } = this.state;
    return (
      <div>
        <input value={myVal} onChange={this.onChange} onBlur={this.validateInput} />
        {error && <div>{error}</div>}
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>

Edit

As a followup to your comments.

To make this solution more generic, i would pass the component a predicate function as a prop, only when the function will return a valid result i would call the onChange that passed from the parent or whatever method you pass that updating the store.

This way you can reuse this pattern in other components and places on your app (or even other projects).

Upvotes: 1

dalinarkholin
dalinarkholin

Reputation: 76

What I have done in the past is to use redux-thunk and joi to solve input constraints/validation using controlled inputs.

In general I like to have one update action that will handle all the field updating. So for example if you have two inputs for a form, it would looks something like this:

render() {
  const { product, updateProduct } = this.props;
  return (
    <div>
      <input 
        value={product.name}
        onChange={() => updateProduct({...product, name: e.target.value})}
      />
      <input 
        value={product.price}
        onChange={() => updateProduct({...product, price: e.target.value})}
      />
    </div>
  )
}

Having one function/action here simplifies my forms a great deal. The updateProject action would then be a thunk action that handles side effects. Here is our Joi Schema(based off your one requirement) and updateProduct Action mentioned above. As a side note, I also tend to just let the user make the mistake. So if they don't enter anything for price I would just make the submit button inactive or something, but still store away null/empty string in the redux store.

const projectSchema = Joi.object().keys({
  name: Joi.number().string(),
  price: Joi.integer().required(), // price is a required integer. so null, "", and undefined would throw an error.
});

const updateProduct = (product) => {
  return (dispatch, getState) {
    Joi.validate(product, productSchema, {}, (err, product) => {
      if (err) {
        // flip/dispatch some view state related flag and pass error message to view and disable form submission;
      }

    });
    dispatch(update(product)); // go ahead and let the user make the mistake, but disable submission
  }
}

I stopped using uncontrolled inputs, simply because I like to capture the entire state of an application. I have very little local component state in my projects. Keep in mind this is sudo code and probably won't work if directly copy pasted. Hope it helps.

Upvotes: 2

NSjonas
NSjonas

Reputation: 12071

So I think I've figure out a decent solution. Basically I needed to:


  1. Create separate component that can control the input with local state.

  2. Pass an onChange handler into the props that I can use to dispatch my changePrice action conditionally

  3. Use componentWillReceiveProps to keep the local value state in sync with the redux store


Code (simplified and in typescript):

interface INumberInputProps {
  value: number;
  onChange: (val: number) => void;
}

interface INumberInputState {
  value: number;
}

export class NumberInput extends React.Component<INumberInputProps, INumberInputState> {

  constructor(props) {
    super(props);
    this.state = {value: props.value};
  }

  public handleChange = (value: number) => {
    this.setState({value});
    this.props.onChange(value);
  }

  //keeps local state in sync with redux store
  public componentWillReceiveProps(props: INumberInputProps){
    if (props.value !== this.state.value) {
      this.setState({value: props.value});
    }
  }

  public render() {
    return <input value={this.state.value} onChange={this.handleChange} />
  }
}

In my Product Component:

  ...
  //conditionally dispatch action if meets valadations
  public handlePriceChange = (price: number) => {
    if (price < this.props.product.standardPrice &&
      price > this.props.product.preferredPrice &&
      !isNaN(price) &&
      lineItem.price !== price){
        this.props.dispatch(updatePrice(this.props.product, price));
    }
  }

  public render() {
     return <NumberInput value={this.props.product.price} onChange={this.handlePriceChange} />
  }

...

Upvotes: 1

Related Questions