Cómo manejar formularios en React desde cero (incluye Hooks)

El proceso que te muestro en este post en realidad fue una experiencia de desarrollo personal mientras trabajaba en una app de React motivado por mejorar (y ahora te la comparto 💪).

El producto de esa experiencia terminó en esta biblioteca que publiqué en npm y ahora te cuento cómo fue el proceso por dentro.


Contenidos:


La manera en que procederemos es ir creando un formulario simple y de manera progresiva vamos a ir haciéndolo mas robusto agregando más inputs, validaciones con una completa UI.

Los ejemplos están en codepen, por lo que eres libre de experimentar con ellos editándolos 🤓

Para poder disfrutar de este post necesitas tener experiencia con React, por lo menos el comprender lo que es un componente y cómo funcionan.

Nota: este post fue creado originalmente cuando aún no existían los hooks, pero no te preocupes, lo he actualizado para agregar una sección con hooks. La lógica sigue siendo la misma ya sea que uses hooks o clases.

Formulario controlado en React JS básico

A continuación partimos con las definiciones elementales.

¿Qué es un formulario controlado en React?

Es un formulario en el que los valores de los inputs son manejados por el state del componente.

Esto significa que el state del componente es la "única fuente de la verdad" que consideraremos para saber qué valores tiene nuestro formulario.

Sin embargo, la única manera de actualizar el state es usando la función setState().

Por lo tanto, un formulario controlado maneja los valores de los inputs en el state y lo actualiza de acuerdo a los eventos del mismo usando setState.

Cómo crear un formulario controlado básico

Vamos a crear nuestro formulario controlado con un solo input para ilustrar el ejemplo y después lo vamos a mejorar.

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      name: '',
    }
  }

  render() {
    const { name } = this.state

    return (
      <div>
        <h1>Simple form</h1>
        <form>
          <label for>
            Name:
            <input
              type="text"
              value={name}
              onChange={(e) => this.setState({ name: e.target.value })}
            />
          </label>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>Name: {this.state.name}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Es un formulario simple donde definimos nuestro state inicial en el constructor colocando la propiedad "name" y como valor una cadena vacía.

En el método render estamos indicando que en el evento change (onChange) vamos a ejecutar un callback y ahí usamos setState según lo que contenga e.target.value (que es igual al valor del input).

Esto funciona pero hay un problema: cada vez que hacemos un cambio en nuestro input, el state se actualiza.

Cuando el state se actualiza, se vuelve a ejecutar render (), o sea que se vuelve a renderizar el contenido con el callback ocasionando que se vuelva a interpretar esa parte del código.

Es una buena práctica que nuestros callbacks ejecuten un método de nuestra clase en vez de colocar ahí toda nuestra lógica.

Para ver un ejemplo más claro de lo anterior, supongamos que tenemos esto dentro de nuestro callback:

<input
  type="text"
  value={name}
  onChange={(e) => {
    const { value } = e.target
    // more code...
    this.setState({ name: value })
  }}
/>

Aparte de que se ve algo sucio, cada que se vuelva a renderizar el componente volverá a interpretar ese código.

Para limpiar esto vamos a crear un método en nuestro componente que llamaremos "handleChange" y dentro colocaremos nuestro setState:

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      name: '',
    }
  }

  handleChange = (e) => {
    const { value } = e.target
    this.setState({ name: value })
  }

  render() {
    const { name } = this.state

    return (
      <div>
        <h1>Simple form</h1>
        <form>
          <label for>
            Name:
            <input type="text" value={name} onChange={this.handleChange} />
          </label>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>Name: {this.state.name}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Si lo probamos sigue funcionando y ya lo tenemos más limpio 😃

Ahora vamos a manejar el evento submit. Para eso, creamos un método que llamaremos "handleSubmit".

En una app real, dentro del manejador del submit, por lo común pasas los datos de tu formulario a un servicio rest para que los envíe a tu backend o despachas acciones si usas Redux.

En nuestro caso, por ser un tutorial, solamente colocaremos un alert que nos diga los datos del formulario.

El código ahora luce así:

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      name: '',
    }
  }

  handleChange = (e) => {
    const { value } = e.target
    this.setState({ name: value })
  }

  handleSubmit = (e) => {
    e.preventDefault()
    alert(this.state.name)
  }

  render() {
    const { name } = this.state

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <label for>
            Name:
            <input type="text" value={name} onChange={this.handleChange} />
          </label>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>Name: {this.state.name}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Algo importante es el e.preventDefault(). Esto nos permite prevenir el comportamiento por default de un submit. Puedes experimentar removiendo esa parte para que veas lo que sucede.

Esto es sólo lo básico. Ahora vamos a agregar más inputs y mensajes de validaciones 😃

Cómo manejar varios inputs en el formulario

Usando nuestro formulario anterior, vamos a agregar un input tipo email y otro input tipo radio button. Para actualizar el estado, vamos usar setState indicando dinámicamente el nombre de la propiedad que queramos actualizar y su valor correspondiente.

Nuestro state ahora luce así:

this.state = {
  name: '',
  email: '',
  gender: '',
}

El nombre de cada propiedad del estado es importante porque vamos a actualizar nuestro state dinámicamente accediendo a los nombres de sus propiedades. En los inputs, la propiedad name deberá ser igual a la que hayamos puesto en nuestro state. Por ejemplo:

<input
  type="email"
  name="email" // <<< email como la propiedad del state
  value={email}
  onChange={this.handleChange}
/>

Con lo anterior preparado, ahora sí podemos actualizar nuestro state dinámicamente según el input modificado.

handleChange = (e) => {
  const { name, value } = e.target
  this.setState({ [name]: value })
}

El objeto e.target contiene información relativa al elemento que desencadenó el evento. Si haces un console.log del objeto e podrás ver todas las propiedades que contiene. La sintaxis:

{ [name]: value }

es propia de la especificada en ES6 y lo que hace es acceder a una propiedad de un objeto según lo que coloquemos como valor en los corchetes. En nuestro caso, el valor es una variable que tendrá un valor diferente según e.target.name. Podemos ver un ejemplo más explicado:

// inside constructor
this.state = { email: 'john.doe@mail.com', name: 'john doe' }
// inside random method
const name = 'email'
this.setState({
  [name]: 'john@mail.com',
})
// the after setState code is equals to
this.setState({
  email: 'john@mail.com',
})

Con base a lo anterior, el atributo name de los inputs nos dicen qué propiedad del state debemos modificar dinámicamente.

Excelente, ya tenemos más inputs funcionando. Si quisieras agregar más inputs sólo debes recordar agregarle el atributo name igual a la propiedad del state que hará referencia.

Cómo agregar validaciones al formulario

Existen muchas maneras diferentes de agregar validaciones a un formulario. El límite es tu imaginación y lo que te permita hacer React.

Lo importante es que pienses con tu propio cerebro cómo podrías agregarlas. En nuestro caso, te voy a mostrar una manera.

Las preguntas a responder son: ¿En qué momento (s) ejecutaremos las validaciones? ¿Cómo las aplicaremos con React?

La primer pregunta puedes delimitarla a: ¿Cómo será la experiencia de usuario que quiero dar?

Con base a esa pregunta, definimos que los momentos en que ejecutaremos las validaciones será cada vez que revisemos si el input es inválido.

Eso pasará en:

  • En el evento submit
  • En el evento change de un input

Y vamos a remover los mensajes de error cuando el input correspondiente ya tenga un valor válido.

Implementar validaciones

Cada input tendrá su propia validación, por lo que vamos a agregar esos valores en el state. Actualmente el state luce como:

this.state = {
  name: '',
  email: '',
  gender: '',
}

Para ser muy claros sobre qué estamos guardando en el state, vamos a dividir entre values y validations.

this.state = {
  values: {
    name: '',
    email: '',
    gender: '',
  },
  validations: {
    name: '',
    email: '',
    gender: '',
  },
}

Y los mostraremos en la UI.

El siguiente código aún no valida nada, solamente lo estamos preparando para que pueda guardar y mostrar los mensajes de validaciones.

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.state = {
      values: {
        name: '',
        email: '',
        gender: '',
      },
      validations: {
        name: '',
        email: '',
        gender: '',
      },
    }
  }

  handleChange = (e) => {
    const { name, value } = e.target
    this.setState({
      values: {
        ...this.state.values,
        [name]: value,
      },
    })
  }

  handleSubmit = (e) => {
    e.preventDefault()
    const values = JSON.stringify(this.state.values)
    alert(values)
  }

  render() {
    const { name, email, gender } = this.state.values
    const { name: nameVal, email: emailVal, gender: genderVal } = this.state.validations

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <div>
            <label>
              Name:
              <input type="text" name="name" value={name} onChange={this.handleChange} />
            </label>
            <div>{nameVal}</div>
          </div>

          <div>
            <label>
              Email:
              <input type="email" name="email" value={email} onChange={this.handleChange} />
            </label>
            <div>{emailVal}</div>
          </div>

          <div>
            <label>
              Female
              <input type="radio" name="gender" value="F" onChange={this.handleChange} />
            </label>
            <label>
              Male
              <input type="radio" name="gender" value="M" onChange={this.handleChange} />
            </label>
            <div>{genderVal}</div>
          </div>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>{JSON.stringify(this.state.values)}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Ahora que tenemos todo listo para mostrar las validaciones, vamos a ir al handleSubmit para validar todos los inputs.

Si todos los inputs son válidos, entonces hay que terminar de ejecutar el handleSubmit (en este ejemplo, mostrará el alert).

Si por lo menos hay un input inválido, entonces no permitir continuar el handleSubmit. De momento, sólo validaremos que nuestros inputs tengan un valor.

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.state = {
      values: {
        name: '',
        email: '',
        gender: '',
      },
      validations: {
        name: '',
        email: '',
        gender: '',
      },
    }
  }

  handleChange = (e) => {
    const { name, value } = e.target
    this.setState({
      values: {
        ...this.state.values,
        [name]: value,
      },
    })
  }

  validateAll = () => {
    const { name, email, gender } = this.state.values
    const validations = { name: '', email: '', gender: '' }
    let isValid = true

    if (!name) {
      validations.name = 'Name is required'
      isValid = false
    }

    if (!email) {
      validations.email = 'Email is required'
      isValid = false
    }

    if (!gender) {
      validations.gender = 'Gender is required'
      isValid = false
    }

    if (!isValid) {
      this.setState({ validations })
    }

    return isValid
  }

  handleSubmit = (e) => {
    e.preventDefault()
    const isValid = this.validateAll()

    if (!isValid) {
      return false
    }

    const values = JSON.stringify(this.state.values)
    alert(values)
  }

  render() {
    const { name, email, gender } = this.state.values
    const { name: nameVal, email: emailVal, gender: genderVal } = this.state.validations

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <div>
            <label>
              Name:
              <input type="text" name="name" value={name} onChange={this.handleChange} />
            </label>
            <div>{nameVal}</div>
          </div>

          <div>
            <label>
              Email:
              <input type="email" name="email" value={email} onChange={this.handleChange} />
            </label>
            <div>{emailVal}</div>
          </div>

          <div>
            <label>
              Female
              <input type="radio" name="gender" value="F" onChange={this.handleChange} />
            </label>
            <label>
              Male
              <input type="radio" name="gender" value="M" onChange={this.handleChange} />
            </label>
            <div>{genderVal}</div>
          </div>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>{JSON.stringify(this.state.values)}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Ya nos valida que por lo menos tenga un valor 😃 Ahora vamos a agregar las validaciones en el evento onBlur.

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.state = {
      values: {
        name: '',
        email: '',
        gender: '',
      },
      validations: {
        name: '',
        email: '',
        gender: '',
      },
    }
  }

  handleChange = (e) => {
    const { name, value } = e.target
    this.setState({
      values: {
        ...this.state.values,
        [name]: value,
      },
    })
  }

  validateAll = () => {
    const { name, email, gender } = this.state.values
    const validations = { name: '', email: '', gender: '' }
    let isValid = true

    if (!name) {
      validations.name = 'Name is required'
      isValid = false
    }

    if (!email) {
      validations.email = 'Email is required'
      isValid = false
    }

    if (!gender) {
      validations.gender = 'Gender is required'
      isValid = false
    }

    if (!isValid) {
      this.setState({ validations })
    }

    return isValid
  }

  validateOne = (e) => {
    const { name } = e.target
    const value = this.state.values[name]
    let message = ''

    if (!value) {
      message = `${name} is required`
    }

    this.setState({
      validations: {
        ...this.state.validations,
        [name]: message,
      },
    })
  }

  handleSubmit = (e) => {
    e.preventDefault()
    const isValid = this.validateAll()

    if (!isValid) {
      return false
    }

    const values = JSON.stringify(this.state.values)
    alert(values)
  }

  render() {
    const { name, email, gender } = this.state.values
    const { name: nameVal, email: emailVal, gender: genderVal } = this.state.validations

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <div>
            <label>
              Name:
              <input
                type="text"
                name="name"
                value={name}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{nameVal}</div>
          </div>

          <div>
            <label>
              Email:
              <input
                type="email"
                name="email"
                value={email}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{emailVal}</div>
          </div>

          <div>
            <label>
              Female
              <input
                type="radio"
                name="gender"
                value="F"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <label>
              Male
              <input
                type="radio"
                name="gender"
                value="M"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{genderVal}</div>
          </div>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>{JSON.stringify(this.state)}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Lo más destacable es el método validateOne.

const { name } = e.target
const value = this.state.values[name] // <<< valor del state

Lo que estamos haciendo es traernos el valor del state del input correspondiente y revisar si no es falsy (cadena vacía es un falsy).

let message = ''

if (!value) {
  message = `${name} is required`
}

Al final, actualizamos nuestro state con el valor de la variable message, la cual al inicio es una cadena vacía y se le asigna un valor en caso de que entre al if.

Agregar varias validaciones a un formulario

Ahora vamos a agregar más validaciones como comprobar que el input email tenga un valor que sí sea un email y que name tenga una longitud mínima y máxima.

class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.state = {
      values: {
        name: '',
        email: '',
        gender: '',
      },
      validations: {
        name: '',
        email: '',
        gender: '',
      },
    }
  }

  handleChange = (e) => {
    const { name, value } = e.target
    this.setState({
      values: {
        ...this.state.values,
        [name]: value,
      },
    })
  }

  validateAll = () => {
    const { name, email, gender } = this.state.values
    const validations = { name: '', email: '', gender: '' }
    let isValid = true

    if (!name) {
      validations.name = 'Name is required'
      isValid = false
    }

    if ((name && name.length < 3) || name.length > 50) {
      validations.name = 'Name must contain between 3 and 50 characters'
      isValid = false
    }

    if (!email) {
      validations.email = 'Email is required'
      isValid = false
    }

    if (email && !/\S+@\S+\.\S+/.test(email)) {
      validations.email = 'Email format must be as example@mail.com'
      isValid = false
    }

    if (!gender) {
      validations.gender = 'Gender is required'
      isValid = false
    }

    if (!isValid) {
      this.setState({ validations })
    }

    return isValid
  }

  validateOne = (e) => {
    const { name } = e.target
    const value = this.state.values[name]
    let message = ''

    if (!value) {
      message = `${name} is required`
    }

    if (value && name === 'name' && (value.length < 3 || value.length > 50)) {
      message = 'Name must contain between 3 and 50 characters'
    }

    if (value && name === 'email' && !/\S+@\S+\.\S+/.test(value)) {
      message = 'Email format must be as example@mail.com'
    }

    this.setState({
      validations: {
        ...this.state.validations,
        [name]: message,
      },
    })
  }

  handleSubmit = (e) => {
    e.preventDefault()
    const isValid = this.validateAll()

    if (!isValid) {
      return false
    }

    const values = JSON.stringify(this.state.values)
    alert(values)
  }

  render() {
    const { name, email, gender } = this.state.values
    const { name: nameVal, email: emailVal, gender: genderVal } = this.state.validations

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <div>
            <label>
              Name:
              <input
                type="text"
                name="name"
                value={name}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{nameVal}</div>
          </div>

          <div>
            <label>
              Email:
              <input
                type="email"
                name="email"
                value={email}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{emailVal}</div>
          </div>

          <div>
            <label>
              Female
              <input
                type="radio"
                name="gender"
                value="F"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <label>
              Male
              <input
                type="radio"
                name="gender"
                value="M"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{genderVal}</div>
          </div>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>{JSON.stringify(this.state)}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Lo mayor a destacar es que solamente hemos agregado más condiciones en nuestros métodos que handlean los eventos change y submit.

Si lo probamos, funciona correctamente, pero el código comienza a tornarse más repetitivo y por lo tanto, algo sucio. Vamos a hacer un refactor 👷🏼 más adelante.

Verás cómo va emergiendo naturalmente el proceso de desacoplamiento con refactoring, lo que nos llevará a aplicar patrones.

Formulario con React hooks

Hasta ahora hemos visto ejemplos usando componentes de tipo clase porque al momento de publicar este post aún no se habían publicado los hooks.

¿Qué son los hooks de React JS?

Los hooks son funciones especiales que nos permiten utilizar características de React en componentes funcionales. Por ejemplo, poder usar variables de estado 😱 y son hermosos 😌.

Ahora vamos a ver el ejemplo equivalente de un formulario usando hooks 😀.

El hook que más vas a usar es el de useState porque nos permite definir variables de estado dentro de un componente funcional.

Si no estás familiarizado con este hook y quieres tener un ejemplo explicado sencillo, desde cero y de manera rápida, te recomiendo que des un ojo a este post:

Hook de estado useState.

El refactor del formulario del ejemplo de validaciones queda como sigue.

import * as React from 'https://cdn.skypack.dev/react@17.0.1'
import * as ReactDOM from 'https://cdn.skypack.dev/react-dom@17.0.1'

const MyForm = () => {
  const [values, setValues] = React.useState({
    name: '',
    email: '',
    gender: '',
  })

  const [validations, setValidations] = React.useState({
    name: '',
    email: '',
    gender: '',
  })

  const validateAll = () => {
    const { name, email, gender } = values
    const validations = { name: '', email: '', gender: '' }
    let isValid = true

    if (!name) {
      validations.name = 'Name is required'
      isValid = false
    }

    if ((name && name.length < 3) || name.length > 50) {
      validations.name = 'Name must contain between 3 and 50 characters'
      isValid = false
    }

    if (!email) {
      validations.email = 'Email is required'
      isValid = false
    }

    if (email && !/\S+@\S+\.\S+/.test(email)) {
      validations.email = 'Email format must be as example@mail.com'
      isValid = false
    }

    if (!gender) {
      validations.gender = 'Gender is required'
      isValid = false
    }

    if (!isValid) {
      setValidations(validations)
    }

    return isValid
  }

  const validateOne = (e) => {
    const { name } = e.target
    const value = values[name]
    let message = ''

    if (!value) {
      message = `${name} is required`
    }

    if (value && name === 'name' && (value.length < 3 || value.length > 50)) {
      message = 'Name must contain between 3 and 50 characters'
    }

    if (value && name === 'email' && !/\S+@\S+\.\S+/.test(value)) {
      message = 'Email format must be as example@mail.com'
    }

    setValidations({ ...validations, [name]: message })
  }

  const handleChange = (e) => {
    const { name, value } = e.target
    setValues({ ...values, [name]: value })
  }

  const handleSubmit = (e) => {
    e.preventDefault()

    const isValid = validateAll()

    if (!isValid) {
      return false
    }

    alert(JSON.stringify(values))
  }

  const { name, email, gender } = values

  const { name: nameVal, email: emailVal, gender: genderVal } = validations

  return (
    <div>
      <h1>Simple form</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label>
            Name:
            <input
              type="text"
              name="name"
              value={name}
              onChange={handleChange}
              onBlur={validateOne}
            />
          </label>
          <div>{nameVal}</div>
        </div>

        <div>
          <label>
            Email:
            <input
              type="email"
              name="email"
              value={email}
              onChange={handleChange}
              onBlur={validateOne}
            />
          </label>
          <div>{emailVal}</div>
        </div>

        <div>
          <label>
            Female
            <input
              type="radio"
              name="gender"
              value="F"
              onChange={handleChange}
              onBlur={validateOne}
            />
          </label>
          <label>
            Male
            <input
              type="radio"
              name="gender"
              value="M"
              onChange={handleChange}
              onBlur={validateOne}
            />
          </label>
          <div>{genderVal}</div>
        </div>

        <button type="submit">Send</button>
      </form>

      <div>
        <h2>Values of the form</h2>
        <p>{JSON.stringify(values)}</p>
      </div>
    </div>
  )
}

Enlace a codepen.

Como puedes ver, el código es mucho menos verboso que cuando teníamos la clase. La principal diferencia es donde definimos las variables de estado:

const [values, setValues] = React.useState({
  name: '',
  email: '',
  gender: '',
})

const [validations, setValidations] = React.useState({
  name: '',
  email: '',
  gender: '',
})

El hook useState retorna un array donde el índice cero tiene la variable de estado y el índice uno, una función que usaremos para actualizar el valor de la variable de estado, por ejemplo, considera la función handleChange como sigue.

const handleChange = (e) => {
  const { name, value } = e.target
  setValues({ ...values, [name]: value })
}

Usamos setValues para actualizar la variable de estado values y ojo 👁, la manera correcta de actualizar una variable de estado que es un objeto es como viene en el ejemplo anterior.

Si quieres saber por qué es la manera correcta y cómo hacer actualizaciones de tipo CRUD a objetos y arrays con useState , te recomiendo que veas este vídeo donde lo explico paso a paso.

Manejo de useState para estructurasY con esto tenemos el ejemplo terminado, en este caso, sólo usamos el hook useState .

Desacoplar validaciones de nuestros componentes

En el proceso de desarrollo siempre se llegan a estos puntos en los que comenzamos a repetir o a agregar complejidad innecesaria.

El código repetitivo y la complejidad innecesaria se deben a que no estamos abstrayendo lo suficiente. - JC

Ahora imagina que tienes otro formulario con los mismos campos, ¿Vas a hacer un copy paste? ahora multiplícalo por 10 formularios.

En esos casos aplicar un copy paste es el inicio de toda maldad en el código ya que hace costoso su mantenimiento, por favor no lo hagas 😵

Podemos abstraer las validaciones en un archivo independiente. A continuación un ejemplo:

class Validator {
  constructor(value) {
    this.value = value
    this.result = []
  }

  isNotEmpty(msg) {
    if (!this.value) {
      this.result.push(msg)
    }

    return this
  }

  isLength(minLength, maxLength, msg) {
    if (this.value.length < minLength || this.value.length > maxLength) {
      this.result.push(msg)
    }

    return this
  }

  isEmail(msg) {
    if (!/\S+@\S+\.\S+/.test(this.email)) {
      this.result.push(msg)
    }

    return this
  }
}

const validatorTest = new Validator('')

const result = validatorTest
  .isNotEmpty('this value must be not empty')
  .isLength(10, 50, 'this value must contain between 2 and 50 characters')
  .isEmail('this value must be a valid email')

alert(JSON.stringify(result))

Enlace a codepen.

Hemos hecho una clase Validator que nos permite encadenar validaciones 😎

Para que el encadenamiento sea posible necesitamos en nuestros métodos retornar la clase misma con this.

En el constructor tenemos una propiedad result que es un array en el que vamos a ir insertando los mensajes conforme se vayan haciendo las validaciones.

Nuestro formulario refactorizado con Validator luce así:

class Validator {
  constructor(value) {
    this.value = value
    this.result = []
  }

  isNotEmpty(msg) {
    if (!this.value) {
      this.result.push(msg)
    }

    return this
  }

  isLength(minLength, maxLength, msg) {
    if (this.value.length < minLength || this.value.length > maxLength) {
      this.result.push(msg)
    }

    return this
  }

  isEmail(msg) {
    if (!/\S+@\S+\.\S+/.test(this.email)) {
      this.result.push(msg)
    }

    return this
  }
}

// Form.js
class Form extends React.Component {
  constructor(props) {
    super(props)
    this.state = this.state = {
      values: {
        name: '',
        email: '',
        gender: '',
      },
      validations: {
        name: [],
        email: [],
        gender: [],
      },
    }
  }

  handleChange = (e) => {
    const { name, value } = e.target
    this.setState({
      values: {
        ...this.state.values,
        [name]: value,
      },
    })
  }

  validateAll = () => {
    const { name, email, gender } = this.state.values
    const validations = { name: '', email: '', gender: '' }

    validations.name = this.validateName(name)
    validations.email = this.validateEmail(email)
    validations.gender = this.validateGender(gender)

    const validationMesages = Object.values(validations).filter(
      (validationMessage) => validationMessage.length > 0
    )
    const isValid = !validationMesages.length

    if (!isValid) {
      this.setState({ validations })
    }

    return isValid
  }

  validateOne = (e) => {
    const { name } = e.target
    const value = this.state.values[name]
    let message = ''

    if (!value) {
      message = `${name} is required`
    }

    if (value && name === 'name' && (value.length < 3 || value.length > 50)) {
      message = 'Name must contain between 3 and 50 characters'
    }

    if (value && name === 'email' && !/\S+@\S+\.\S+/.test(value)) {
      message = 'Email format must be as example@mail.com'
    }

    this.setState({
      validations: {
        ...this.state.validations,
        [name]: message,
      },
    })
  }

  validateName = (name) => {
    const validatorName = new Validator(name)
    return validatorName
      .isNotEmpty('Name is required')
      .isLength(3, 50, 'Name must contain between 3 and 50 characters').result
  }

  validateEmail = (email) => {
    const validatorEmail = new Validator(email)
    return validatorEmail
      .isNotEmpty('Email is required')
      .isEmail('Email format must be as example@mail.com').result
  }

  validateGender = (gender) => {
    const validatorGender = new Validator(gender)
    return validatorGender.isNotEmpty('Gender is required').result
  }

  handleSubmit = (e) => {
    e.preventDefault()
    const isValid = this.validateAll()

    if (!isValid) {
      return false
    }

    const values = JSON.stringify(this.state.values)
    alert(values)
  }

  render() {
    const { name, email, gender } = this.state.values
    const { name: nameVal, email: emailVal, gender: genderVal } = this.state.validations

    return (
      <div>
        <h1>Simple form</h1>
        <form onSubmit={this.handleSubmit}>
          <div>
            <label>
              Name:
              <input
                type="text"
                name="name"
                value={name}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{nameVal.length > 0 && nameVal.map((msg) => <div>{msg}</div>)}</div>
          </div>

          <div>
            <label>
              Email:
              <input
                type="email"
                name="email"
                value={email}
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{emailVal.length > 0 && emailVal.map((msg) => <div>{msg}</div>)}</div>
          </div>

          <div>
            <label>
              Female
              <input
                type="radio"
                name="gender"
                value="F"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <label>
              Male
              <input
                type="radio"
                name="gender"
                value="M"
                onChange={this.handleChange}
                onBlur={this.validateOne}
              />
            </label>
            <div>{genderVal.length > 0 && genderVal.map((msg) => <div>{msg}</div>)}</div>
          </div>

          <button type="submit">Send</button>
        </form>

        <div>
          <h2>Values of the form</h2>
          <p>{JSON.stringify(this.state)}</p>
        </div>
      </div>
    )
  }
}

Enlace a codepen.

Hemos desacoplado totalmente las validaciones de nuestro formulario, esto nos permite reutilizarlo en otros componentes e incluso en código JS en general tanto en el browser como en el backend con Node pues es JS vanilla 😃 💅

Y como he mencionado antes, esto e sólo una manera de resolver el problema de repetición de código, seguro hay muchas otras.

De ahora en adelante ya tienes una opción para facilitar tus validaciones y lo puedes usar en los formularios que desees.

Manejos más avanzados en formularios con React

Con lo visto hasta ahora ya tenemos formularios un poco más limpios.

Lo único es que cada que hagamos un formulario controlado nuevo vamos a estar repitiendo la misma lógica de estar actualizando el state y los mensajes de validaciones.

Todavía podemos abstraer aún más nuestra lógica de formulario separándola de la UI 😮

Esto es un tema un poco más avanzado pues vamos a usar un HOC (High Order Component).

HOC en formulario en React JS

Un HOC no es más que una función que recibe un componente y te retorna ese componente enriquecido con nuevo comportamiento y/o props.

Este es un patrón usado para rehusar funcionalidad y muchas librerías lo usan, por ejemplo react-redux y react-router. Nosotros lo usaremos también.

Un ejemplo de un HOC muy simple y a efectos de muestra sería una función que recibiendo un componente, le agregará el comportamiento que al darle click nos mostrará un alert.

El código del HOC luce así.

const WithAlertHOC = (Component) => {
  const ComponentWithAlert = (props) => {
    const handleAlert = (e) => {
      e.preventDefault()
      alert('alert from HOC')
    }

    return <Component handleAlert={handleAlert} {...props} />
  }

  return ComponentWithAlert
}

Una función que recibe por parámetro un componente y retorna el componente enriquecido.

Lo que vemos es la función que recibe al componente original y dentro está creando un nuevo componente que tiene el comportamiento del alert deseado. Este nuevo componente va a renderizar al componente original inyectándole la función handleAlert y el resto de los props que reciba {...props}.

Ahora hay que verlo en acción agregando este comportamiento a dos componentes, un Text y un Img a muestra de ejemplo.

const WithAlertHOC = (Component) => {
  const ComponentWithAlert = (props) => {
    const handleAlert = (e) => {
      e.preventDefault()
      alert('alert from HOC')
    }

    return <Component handleAlert={handleAlert} {...props} />
  }

  return ComponentWithAlert
}

const Text = ({ handleAlert, content }) => <p onClick={handleAlert}>{content}</p>

const Img = ({ handleAlert, src }) => (
  <img onClick={handleAlert} src={src} width={250} height={250} />
)

const TextWithAlert = WithAlertHOC(Text)
const ImgWithAlert = WithAlertHOC(Img)

const RootComponent = () => (
  <div>
    <TextWithAlert content="My UI component example" />
    <ImgWithAlert src="https://upload.wikimedia.org/wikipedia/commons/a/a7/React-icon.svg" />
  </div>
)

Enlace a codepen.

Ahora vamos a crear un HOC que reciba por parámetro un componente de la UI del formulario y nosotros retornaremos ese formulario pero controlado 😱 Para mantenerlo simple, únicamente vamos a aplicar la lógica para actualizar los valores del formulario sin aplicar validaciones (a efectos de enseñanza). Lo llamaremos WithFormControlled y luce así:

const WithFormControlled = (Form, initialState) =>
  class FormWithControlled extends React.Component {
    constructor(props) {
      super(props)

      this.state = {
        ...initialState,
      }
    }

    handleChange = (e) => {
      const { name, value } = e.target
      this.setState({ [name]: value })
    }

    handleSubmit = (e) => {
      e.preventDefault()
      this.props.handleSubmit(this.state)
    }
    render() {
      return (
        <Form
          {...this.props}
          values={this.state}
          _handleSubmit={this.handleSubmit}
          _handleChange={this.handleChange}
        />
      )
    }
  }

Este HOC en particular recibe como segundo parámetro un objeto que representa el estado inicial de nuestro formulario.

El tipo de componente que creamos ahora es en una clase en vez de una función, esto debido a que estamos usando el state.

Lo que vamos a inyectar a nuestro componente Form son los métodos handleSubmit, handleChange y un prop llamado values que es igual al state.

Pasamos los métodos con guión bajo debido a que queremos identificar que estos métodos son del HOC.

Ahora vamos a implementarlo!

const WithFormControlled = (Form, initialState) =>
  class FormWithControlled extends React.Component {
    constructor(props) {
      super(props)

      this.state = {
        ...initialState,
      }
    }

    handleChange = (e) => {
      const { name, value } = e.target
      this.setState({ [name]: value })
    }

    handleSubmit = (e) => {
      e.preventDefault()
      this.props.handleSubmit(this.state)
    }

    render() {
      return (
        <Form
          {...this.props}
          values={this.state}
          _handleSubmit={this.handleSubmit}
          _handleChange={this.handleChange}
        />
      )
    }
  }

const Form = ({ _handleChange, _handleSubmit, values }) => (
  <form onSubmit={_handleSubmit}>
    <label>
      <input type="text" name="name" onChange={_handleChange} value={values.name} />
      <input type="email" name="email" onChange={_handleChange} value={values.email} />
      <button type="submit">Oh, click me!</button>
    </label>
  </form>
)

const initialState = { name: '', email: '' }
const FormControlled = WithFormControlled(Form, initialState)

const handleSubmitFormControlled = (formValues) => alert(JSON.stringify(formValues))

ReactDOM.render(
  <FormControlled handleSubmit={handleSubmitFormControlled} />,
  document.getElementById('app')
)

Enlace a codepen.

Nuestro componente de UI Form recibe por props lo que nuestro HOC le está inyectando.

const Form = ({
_handleChange,
_handleSubmit,
values
}) ...

Y usamos esos valores en nuestros elementos.

<input type="text" name="name" onChange={_handleChange} value={values.name} />

Algo importante es el submit.

const handleSubmitFormControlled = (formValues) => (
alert(JSON.stringify(formValues))
)
<FormControlled handleSubmit={handleSubmitFormControlled} />

Estamos definiendo lo que queremos que haga el submit para éste formulario en particular. handleSubmit es nuestra función particular y _handleSubmit es la función (o método) del HOC.

handleSubmitFormControlled recibe por parámetro los valores del state debido a que así lo estamos ejecutando en el _handleSubmit del HOC:

handleSubmit = (e) => {
  e.preventDefault()
  this.props.handleSubmit(this.state)
}

Y listo! tenemos un prototipo inicial para crear formularios controlados con un HOC! 🔥🔥🔥

En realidad, esto es una versión minimalista de la librería que publiqué en npm, increíble, ¿no? 🤓.

Lo que hemos visto no es otra cosa más que JS vanilla y React puros, sin otras librerías.

Hemos visto cómo desacoplar lógica de nuestra UI y sin duda esto nos permite crear un código más mantenible en nuestros proyectos.

Existen diferentes caminos para llegar al mismo destino, si conoces uno compártelo en los comentarios!


🔔 Bonus: ¿Te gustaría dar un paso al siguiente nivel en React JS?

Te dejo links de mis cursos premium de Udemy donde te ayudo a subir tu nivel de seniority 🤓 ☕️.