Skip to content

Instantly share code, notes, and snippets.

@andrewk
Created March 29, 2015 10:08
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save andrewk/0bd6080c43bf485d665b to your computer and use it in GitHub Desktop.
Save andrewk/0bd6080c43bf485d665b to your computer and use it in GitHub Desktop.
Good Enough™ Form Validation in React

Good Enough™ Form Validation in React

…specifically React 0.13 using ES6 class syntax

Originally I was implementing the validator as a context, but then I got stung by parent context vs owner context. I'll likely return to the context model when React's context implementation is more final (looks like they're moving towards parent context over owner context, which I'd prefer).

Requirements

  • markup must match our existing markup, for UX consistency.
  • inputs are re-usable components with an explicit contract -- they are responsponsible for their error display and any required filtering of their value.

Basic Form

export default class NewCreditCardForm extends React.Component {
  constructor() {
    this.state = {
      fields: ['name', 'number', 'expiry', 'cvc']
    }
  }

  render() {
    return (
      <form ref="newCard" onSubmit={this.submit.bind(this)}>
        <Input ref="name"
          attr={{ placeholder: 'Ben Franklin' }}
          label="Full name"
          name="name"
          validator={ v => v.trim().length > 0 }
          validationMessage="Card holder's name is required"
          />

        <Input ref="number"
          label="Card number"
          name="number"
          attr={{ placeholder: '1234 5678 9012 3456' }}
          filter={ v => v.replace(/-|\s/g, '') }
          validator={ v => v.match(/^\d{16}$/) }
          validationMessage="Entered card number is invalid"
          />

        <div className="fieldset">
          <Input ref="expiry"
            label="Expiration"
            name="expiry"
            attr={{ placeholder: '04/15' }}
            filter={ v => v.trim() }
            validator={ v => v.match(/^\d{2}\/\d{2}$/) }
            validationMessage="Must be in MM/YY format"
            extraClasses="field--half"
            />

          <Input ref="cvc"
            label="CVC"
            name="cvc"
            attr={{ placeholder: '123' }}
            filter={ v => v.trim() }
            validationMessage="CVC is a 3 digit number"
            validator={ v => v.match(/^\d{3}$/) }
            extraClasses="field--half"
            />
        </div>

        <PayButton payment={this.props.payment} />
      </form>
    )
  }

  submit(e) {
    e.preventDefault()
    
    let isValid = true

    this.state.fields.forEach((ref) => {
      isValid = this.refs[ref].validate() && isValid
    })

    if (isValid) {
      this.props.onSuccess(this.formData())
    }
  }

  formData() {
    const data = {}
    this.state.fields.forEach(ref => {
      data[ref] = this.refs[ref].state.value
    })
    return data
  }
}

NewCreditCardForm.propTypes = {
  payment: React.PropTypes.object.isRequired,
}

NewCreditCardForm.contextTypes = {
  router: React.PropTypes.func.isRequired,
  flux: React.PropTypes.object.isRequired
}

Input Component

export default class Input extends React.Component {
  constructor() {
    this.state = { value: '', error: '' }
  }

  handleChange(event) {
    const newValue = this.props.filter 
      ? this.props.filter(event.target.value) 
      : event.target.value 

    this.setState({ value: newValue})
  }

  validate() {
    if (this.props.validator && !this.props.validator.call(undefined, this.state.value)) {
      this.setState({ error: this.props.validationMessage })
      return false
    }
 
    this.setState({ error: '' })
    return true
  }

  render() {
    const attr = this.props.attr || {}
    const type = attr.type || 'text'
    const classes = ['field']
    attr.id = attr.id || this.props.name
    const value = this.props.value
    const hasError = !(this.state.error === undefined || this.state.error.length === 0)

    if (this.props.extraClasses) {
      classes.push(this.props.extraClasses)
    }

    return (
      <div data-field
        data-field-error={hasError}
        className={classes.join(' ')}>
        <label className="field__title" htmlFor={attr.id}>{this.props.label}</label>

        <FieldError message={this.state.error} />
        <div className="field__input">
          <input type={type}
            className="input-text"
            value={value}
            onChange={this.handleChange.bind(this)}
            {...attr}
          />
        </div>
      </div>
    )
  }
}

Input.propTypes = {
  label: React.PropTypes.string,
  name: React.PropTypes.string,
  extraClasses: React.PropTypes.string,
}

Default error message

export default class FieldError extends PureComponent {
  render() {
    return (
      <div className="field__validation">
        {this.props.message}
      </div>
    )
  }
}

FieldError.propTypes = {
  message: React.PropTypes.string,
}

ControllerComponent

Using the form

export default class NewCreditCard extends React.Component {
  render() {
    const payment = this.context.flux.getStore('payment').getPayment()

    return (
      <div>
        <NewCreditCardForm payment={payment} onSuccess={this.newCard.bind(this)} />
      </div>
    )
  }

  // this method receives the validated, filtered data.
  newCard(data) {
    this.context.flux.getActions('creditcards').newCreditCard(data)
    this.context.router.transitionTo('whatever')
  }
}

NewCreditCard.contextTypes = {
  router: React.PropTypes.func.isRequired,
  flux: React.PropTypes.object.isRequired
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment