Formulario en React con MUI: guía para principiantes

Gabriel Jiménez | Hace 4 días

Los formularios son un elemento muy utilizados en aplicaciones frontend, ya sean web, móvil y de escritorio. Diseñarlos bien desde un inicio, nos permite que sean más fáciles de mantener y escalar. Al diseñarlos, lo primero es elegir una librería de estilos y luego determinar un flujo de envío hacia el backend. 


Para este ejercicio, utilizaremos Material UI como librería de estilos e implementaremos nuestro propio flujo de envío de formularios.

¿Qué es Material UI?

En el mercado existen librerías enfocadas en crear específicamente formularios. En el caso de Material UI no es una librería 100 % enfocada en crear formularios, sino un set de herramientas para crear interfaces gráficas para la web. Para entender mejor, veamos la figura 1.

Figura 1. ¿Qué es Material UI?


La figura 1, muestra las herramientas que ofrece Material UI como componentes y utilidades: temas, paletas de colores, breakpoints, transiciones, variables CSS y más.
Sin embargo, ninguna de sus herramientas esta centrada específicamente en la creación de formularios. Aquí tenemos de tres opciones:
 
  1. Ocupar una librería para crear formularios como Formik, React Final Form o React Hook Form con Material UI.
  2. Crear nuestro propio flujo de envío de formularios con Material UI.
  3. Utilizar únicamente los componentes de Material UI — sin librerías externas, ni sistemas propios de envío de formularios.

Cualquiera de las opciones es totalmente valida, pero elegir una sobre otra, dependerá del contexto del proyecto, por ejemplo:

  • Experiencia de los desarrolladores. Si el equipo esta conformado mayormente por Juniors, agregar más capas de extracción puede volver el desarrollo difícil. Tal vez elegir la tercera opción sería lo correcto.
  • Tamaño del equipo. Entre más grande sea el equipo, mayor riesgo de tener formularios inconsistentes entre cada uno. Para evitar esto, usar la primera o segunda opción.
  • Cantidad de formularios. Si tenemos 5 formularios — por decir un número — conviene utilizar la tercera opción, para evitar agregar complejidad innecesaria.
  • Mantener consistencia entre los formularios. Si es importante mantener entre cada formulario los mismos estilos y el mismo flujo de envío del formulario, utilizar la primera o segunda opción sería lo mejor.

Para este ejercicio, vamos a usar la segunda opción: Crear nuestro propio flujo de envío de formularios con Material UI.


¿Qué es un flujo de envío de formularios?

Para entender mejor qué es un flujo de envío de formularios, veamos la figura 2.

Figura 2. Flujo de envío de formularios.


Cómo se muestra en la figura 2, un flujo de formularios inicia con la inicialización de los campos y la configuración de las validaciones de cada campo. Después, el usuario ingresa los datos, se ejecutan las validaciones, la información se envía al backend y finalmente el frontend muestra la respuesta.
Este flujo solemos repetirlo bastante en nuestros formularios, es por ello, que las librerías para crear formularios son de gran ayuda, ya que encapsulan toda esa lógica y mantienen lineamientos específicos para crear formularios.
NOTA: Al agregar más capas de abstracción — librerías externas— en tus desarrollos, puede volver más lento el desarrollo si no se diseña bien desde un inicio.
También esta la otra opción: crear nuestro propio flujo de envío de formularios.

Diseño de un flujo de envío de formularios

Para nuestro diseño, te recomiendo que le eches un vistazo al artículo: Cómo hacer un formulario con React, desde 0, para saber más sobre el diseño.


Implementación

De forma muy general, en el artículo propongo crear un componente Form, para encapsular como crear formularios de una forma más organizada.

. . .
   35:   return (
   36:     <Form
   37:       onSubmit={handleSubmit}
   38:       onCancel={() => console.log("Cancelado")}
   39:     >
   40:       <TextField name="nombre" id="nombre">Nombre del cliente</TextField>
   41:       <TextField name="mascota" id="mascota">Nombre de la mascota</TextField>
   42:       <SelectField
   43:         name="raza"
   44:         id="raza"
   45:         options={[
   46:           { value: "chihuahua", label: "Chihuahua" },
   47:           { value: "pastor-aleman", label: "Pastor Alemán" },
   48:         ]}
   49:       >
   50:         Raza del perro
   51:       </SelectField>
   52:       <TextAreaField name="descripcion" id="descripcion">Sobre su mascota</TextAreaField>
   53:       <TextField name="cita" id="cita" type="date">Fecha de cita</TextField>
   54: 
   55:       <ActionField type="submit">Guardar</ActionField>
   56:       <ActionField type="cancel">Cancelar</ActionField>
. . .
   68:     </Form>
. . .


Sin embargo, al usar el formulario habías varias limitaciones.


Analicemos el siguiente código:

. . .
    6: function App() {
    7:   const [errors, setErrors] = useState([])
    8: 
    9:   function validate(values) {
   10:     const newErrors = []
   11:     const today = new Date().toISOString().split("T")[0] // yyyy-mm-dd
   12: 
   13:     if (!values.nombre) newErrors.push("El nombre del cliente es obligatorio")
   14:     if (!values.mascota) newErrors.push("El nombre de la mascota es obligatorio")
   15:     if (!values.raza) newErrors.push("La raza es obligatoria")
   16:     if (!values.cita) {
   17:       newErrors.push("La fecha de cita es obligatoria")
   18:     } else if (values.cita < today) {
   19:       newErrors.push("La fecha debe ser hoy o posterior")
   20:     }
   21: 
   22:     return newErrors
   23:   }
   24: 
   25:   function handleSubmit(values) {
   26:     const validationErrors = validate(values)
   27:     if (validationErrors.length > 0) {
   28:       setErrors(validationErrors)
   29:       return
   30:     }
   31:     setErrors([])
   32:     console.log("Formulario válido ✅", values)
   33:   }
. . .
   58:       {errors.length > 0 && (
   59:         <div className="form-errors">
   60:           <h4>Errores:</h4>
   61:           <ul>
   62:             {errors.map((err, i) => (
   63:               <li key={i}>{err}</li>
   64:             ))}
   65:           </ul>
   66:         </div>
   67:       )}
. . .

  1. Quien implementa el formulario es responsable por implementar las validaciones y mostrar los errores.
  2. Diferentes formularios pueden tener una implementación distinta al resolver las validaciones y mostrar los errores.
  3. Si queremos implementar un Spinner para deshabilitar botones o pantallas cuando el formulario se envía al backend, tendríamos que replicarlo en cada uno de los formularios.

Y la lista puede ir creciendo…

Vamos a atacar esos problemas, y a la par integremos Material UI.




Validaciones y errores dentro del componente Form

A grandes rasgos lo que queremos lograr es que, el formulario gestione los errores de los campos internamente.


Componente Form con estado errors

    1: import React, { useState, Children, cloneElement } from 'react';
    2: 
    3: export const Form = ({ children, onSubmit, onCancel }) => {
. . .
   22:   const [errors, setErrors] = useState(
   23:     Object.keys(initialValues).reduce((acc, key) => {
   24:       acc[key] = [];
   25:       return acc;
   26:     }, {})
   27:   );
. . .
   34:   function runValidations(rules, value) {
   35:     if (!rules) return [];
   36: 
   37:     const msgs = [];
   38: 
   39:     rules.forEach((rule) => {
   40:       if (rule === 'required') {
   41:         if (!value || value.toString().trim() === '') {
   42:           msgs.push('Este campo es obligatorio');
   43:         }
   44:       } else if (rule.startsWith('max:')) {
   45:         const max = parseInt(rule.split(':')[1], 10);
   46:         if (value && value.length > max) {
   47:           msgs.push(`Debe tener máximo ${max} caracteres`);
   48:         }
   49:       } else if (rule.startsWith('min:')) {
   50:         const min = parseInt(rule.split(':')[1], 10);
   51:         if (!value || value.length < min) {
   52:           msgs.push(`Debe tener al menos ${min} caracteres`);
   53:         }
   54:       }
   55:     });
   56: 
   57:     return msgs;
   58:   }
   59: 
   60:   function handleSubmit(e) {
   61:     e.preventDefault();
   62: 
   63:     const nextErrors = {};
   64:     fields.forEach((child) => {
   65:       const name = child.props.name;
   66:       const value = values[name];
   67:       const fieldErrors = runValidations(child.props.validations, value);
   68:       nextErrors[name] = fieldErrors;
   69:     });
   70: 
   71:     const hasAnyError = Object.values(nextErrors).some((arr) => arr.length > 0);
   72: 
   73:     setErrors(nextErrors);
   74: 
   75:     if (hasAnyError) {
   76:       return;
   77:     }
   78: 
   79:     onSubmit?.(values);
   80:   }
   81: 
   82:   return (
   83:     <form onSubmit={handleSubmit} noValidate>
   84:       {fields.map((child) =>
   85:         cloneElement(child, {
   86:           key: child.props.name,
   87:           value: values[child.props.name],
   88:           onChange: handleChange,
   89:           errorMessages: errors[child.props.name] || [],
   90:         })
   91:       )}
   92: 
   93:       {actions.map((child, idx) =>
   94:         cloneElement(child, {
   95:           key: `action-${idx}`,
   96:           onCancel,
   97:         })
   98:       )}
   99:     </form>
  100:   );
  101: };
. . .


Analicemos


Línea 22-23

Creamos un estado errors a partir de los campos initialValues. Donde el key es el nombre del campo y el valor un arreglo de errores.


Línea 34-55

Función que devuelve los mensajes de errores, si alguna validación falla.


Línea 63-69

Ejecuta las validaciones de los campos y si tienen errores los almacena en un objeto.


Línea 71

Verifica si existe algún error.


Línea 74

Actualiza el estado errors.


Línea 75-77

Si existe un error, no sé ejecuta invoca el onSubmit.


Ya tenemos los errores de cada campo, ahora toca pasarlos a los componentes TextField, TextAreaField, SelectField.


Prop errors en cada campo

. . .   
   82:   return (
   83:     <form onSubmit={handleSubmit} noValidate>
   84:       {fields.map((child) =>
   85:         cloneElement(child, {
   86:           key: child.props.name,
   87:           value: values[child.props.name],
   88:           onChange: handleChange,
   89:           errors: errors[child.props.name] || [],
   90:         })
   91:       )}
. . .
   99:     </form>
  100:   );
. . .


Para mostrar los errores, solo tenemos que iterar en el arreglo de errores.

. . .
  103: export const TextField = ({ name, id, children, type = 'text', value, onChange, errors = [] }) => {
  104:   const label = typeof children === 'string' ? children : '';
  105: 
  106:   return (
  107:     <div className="textfield">
  108:       <label htmlFor={id || name}>{label}</label>
  109:       <input type={type} name={name} id={id || name} value={value} onChange={onChange} />
  110:       {errors.length > 0 && (
  111:         <div className="field-errors">
  112:           {errors.map((msg, i) => (
  113:             <div key={i} className="field-error">
  114:               {msg}
  115:             </div>
  116:           ))}
  117:         </div>
  118:       )}
  119:     </div>
  120:   );
  121: };
  122: 
  123: export const TextAreaField = ({ name, id, children, value, onChange, errors = [] }) => {
  124:   const label = typeof children === 'string' ? children : '';
  125: 
  126:   return (
  127:     <div className="textareafield">
  128:       <label htmlFor={id || name}>{label}</label>
  129:       <textarea name={name} id={id || name} value={value} onChange={onChange} />
  130:       {errors.length > 0 && (
  131:         <div className="field-errors">
  132:           {errors.map((msg, i) => (
  133:             <div key={i} className="field-error">
  134:               {msg}
  135:             </div>
  136:           ))}
  137:         </div>
  138:       )}
  139:     </div>
  140:   );
  141: };
  142: 
  143: export const SelectField = ({ name, id, options = [], children, value, onChange, errors = [] }) => {
  144:   const label = typeof children === 'string' ? children : '';
  145: 
  146:   return (
  147:     <div className="selectfield">
  148:       <label htmlFor={id || name}>{label}</label>
  149:       <select name={name} id={id || name} value={value} onChange={onChange}>
  150:         <option value="">Selecciona una opción</option>
  151:         {options.map((option, index) => (
  152:           <option key={index} value={option.value}>
  153:             {option.label ?? option.value}
  154:           </option>
  155:         ))}
  156:       </select>
  157:       {errors.length > 0 && (
  158:         <div className="field-errors">
  159:           {errors.map((msg, i) => (
  160:             <div key={i} className="field-error">
  161:               {msg}
  162:             </div>
  163:           ))}
  164:         </div>
  165:       )}
  166:     </div>
  167:   );
  168: };
. . .


Y finalmente, para usar el formulario solo hay que agregar el prop validations a los campos.

    1: import React from 'react'
    2: import {ActionField, Form, SelectField, TextAreaField, TextField} from "./components/Form/Form";
    3: 
    4: function App() {
    5:   return (
    6:     <Form onSubmit={(vals) => console.log('OK', vals)}>
    7:       <TextField
    8:         name="nombre"
    9:         validations={["required", "min:3"]}
   10:       >
   11:         Nombre
   12:       </TextField>
   13: 
   14:       <SelectField
   15:         name="raza"
   16:         options={[
   17:           { value: 'chihuahua', label: 'Chihuahua' },
   18:           { value: 'pastor-aleman', label: 'Pastor Alemán' },
   19:         ]}
   20:         validations={["required"]}
   21:       >
   22:         Raza
   23:       </SelectField>
   24: 
   25:       <TextAreaField
   26:         name="observaciones"
   27:         validations={["max:50"]}
   28:       >
   29:         Observaciones
   30:       </TextAreaField>
   31: 
   32:       <ActionField type="submit">Guardar</ActionField>
   33:       <ActionField type="cancel">Cancelar</ActionField>
   34:     </Form>
   35: 
   36:   )
   37: }
   38: 
   39: export default App;


Perfecto, se bien y es funcional.

Ahora solo falta adaptar los componentes de Material UI. Para lograrlo, solo debemos intervenir en los componentes TextField, TextAreaField y SelectField.

. . .
  106: export const TextField = ({
  107:                             name,
  108:                             id,
  109:                             children,
  110:                             type = 'text',
  111:                             value,
  112:                             onChange,
  113:                             errors = [],
  114:                             ...muiProps
  115:                           }) => {
  116:   const label = typeof children === 'string' ? children : '';
  117:   const hasError = errors.length > 0;
  118: 
  119:   return (
  120:     <MuiTextField
  121:       {...muiProps}
  122:       margin="normal"
  123:       type={type}
  124:       name={name}
  125:       id={id || name}
  126:       label={label}
  127:       value={value ?? ''}
  128:       onChange={onChange}
  129:       error={hasError}
  130:       helperText={hasError ? errors[0] : (muiProps.helperText ?? ' ')}
  131:     />
  132:   );
  133: };
  134: 
  135: export const TextAreaField = ({
  136:                                 name,
  137:                                 id,
  138:                                 children,
  139:                                 value,
  140:                                 onChange,
  141:                                 errors = [],
  142:                                 minRows = 3,
  143:                                 ...muiProps
  144:                               }) => {
  145:   const label = typeof children === 'string' ? children : '';
  146:   const hasError = errors.length > 0;
  147: 
  148:   return (
  149:     <MuiTextField
  150:       {...muiProps}
  151:       margin="normal"
  152:       multiline
  153:       minRows={minRows}
  154:       name={name}
  155:       id={id || name}
  156:       label={label}
  157:       value={value ?? ''}
  158:       onChange={onChange}
  159:       error={hasError}
  160:       helperText={hasError ? errors[0] : (muiProps.helperText ?? ' ')}
  161:     />
  162:   );
  163: };
  164: 
  165: export const SelectField = ({
  166:                               name,
  167:                               id,
  168:                               options = [],
  169:                               children,
  170:                               value,
  171:                               onChange,
  172:                               errors = [],
  173:                               ...muiProps
  174:                             }) => {
  175:   const label = typeof children === 'string' ? children : '';
  176:   const hasError = errors.length > 0;
  177:   const labelId = `${id || name}-label`;
  178: 
  179:   return (
  180:     <FormControl margin="normal" error={hasError} {...muiProps.FormControlProps}>
  181:       <InputLabel id={labelId} {...muiProps.InputLabelProps}>{label}</InputLabel>
  182:       <MuiSelect
  183:         {...muiProps}
  184:         labelId={labelId}
  185:         id={id || name}
  186:         name={name}
  187:         value={value ?? ''}
  188:         label={label}
  189:         onChange={onChange}
  190:       >
  191:         <MenuItem value="">
  192:           <em>Selecciona una opción</em>
  193:         </MenuItem>
  194:         {options.map((option, index) => (
  195:           <MenuItem key={index} value={option.value} {...muiProps.MenuItemProps}>
  196:             {option.label ?? option.value}
  197:           </MenuItem>
  198:         ))}
  199:       </MuiSelect>
  200:       <FormHelperText>
  201:         {hasError ? errors[0] : (muiProps.helperText ?? ' ')}
  202:       </FormHelperText>
  203:     </FormControl>
  204:   );
  205: };
. . .


Varias cosas que resaltar:


  1. El uso de Material UI para aplicar estilos a los campos.
  2. Cada campo es configurable a través de los props nativos de Material UI.
  3. Limitamos la sobreescritura de ciertas propiedades (por ejemplo, el type del TextField) para mantener un comportamiento controlado.

Conclusión

Integrar cualquier librería de estilos como Material UI en la creación de formularios es sencillo, siempre y cuando hayamos diseñado correctamente el flujo de envío de formularios.


Si quieres aprender más sobre como crear formularios más complejos, te invito a leer mi libro: Testing en React: Guía práctica con Jest y React Testing Library.