Creación de su propia biblioteca de validación de React: conceptos básicos (parte 1)
Publicado: 2022-03-10Siempre pensé que las bibliotecas de validación de formularios eran geniales. Lo sé, es un interés de nicho tener, ¡pero los usamos mucho! Al menos en mi trabajo, la mayor parte de lo que hago es construir formularios más o menos complejos con reglas de validación que dependen de opciones y rutas anteriores. Comprender cómo funcionaría una biblioteca de validación de formularios es primordial.
El año pasado, escribí una de esas bibliotecas de validación de formularios. Lo llamé "Calidación", y puede leer la publicación introductoria del blog aquí. Es una buena biblioteca que ofrece mucha flexibilidad y utiliza un enfoque ligeramente diferente al resto del mercado. Sin embargo, también hay muchas otras bibliotecas geniales: la mía funcionó bien para nuestros requisitos.
Hoy, le mostraré cómo escribir su propia biblioteca de validación para React. Repasaremos el proceso paso a paso y encontrará ejemplos de CodeSandbox a medida que avanzamos. Al final de este artículo, sabrá cómo escribir su propia biblioteca de validación o, al menos, tendrá una comprensión más profunda de cómo otras bibliotecas implementan "la magia de la validación".
- Parte 1: Los fundamentos
- Parte 2: Las características
- Parte 3: La Experiencia
Paso 1: Diseño de la API
El primer paso para crear cualquier biblioteca es diseñar cómo se utilizará. Sienta las bases para gran parte del trabajo por venir y, en mi opinión, es la decisión más importante que tomará en su biblioteca.
Es importante crear una API que sea "fácil de usar" y, sin embargo, lo suficientemente flexible como para permitir mejoras futuras y casos de uso avanzados. Intentaremos alcanzar ambos objetivos.
Vamos a crear un enlace personalizado que aceptará un solo objeto de configuración. Esto permitirá que se pasen opciones futuras sin introducir cambios importantes.
Una nota sobre los ganchos
Hooks es una forma bastante nueva de escribir React. Si ha escrito React en el pasado, es posible que no reconozca algunos de estos conceptos. En ese caso, echa un vistazo a la documentación oficial. Está increíblemente bien escrito y lo lleva a través de los conceptos básicos que necesita saber.
Vamos a llamar a nuestro hook personalizado useValidation
por ahora. Su uso podría ser algo como esto:
const config = { fields: { username: { isRequired: { message: 'Please fill out a username' }, }, password: { isRequired: { message: 'Please fill out a password' }, isMinLength: { value: 6, message: 'Please make it more secure' } } }, onSubmit: e => { /* handle submit */ } }; const { getFieldProps, getFormProps, errors } = useValidation(config);
El objeto de config
acepta una propiedad de fields
, que configura las reglas de validación para cada campo. Además, acepta una devolución de llamada para cuando se envía el formulario.
El objeto de fields
contiene una clave para cada campo que queremos validar. Cada campo tiene su propia configuración, donde cada clave es un nombre de validador y cada valor es una propiedad de configuración para ese validador. Otra forma de escribir lo mismo sería:
{ fields: { fieldName: { oneValidator: { validatorRule: 'validator value' }, anotherValidator: { errorMessage: 'something is not as it should' } } } }
Nuestro useValidation
devolverá un objeto con algunas propiedades: getFieldProps
, getFormProps
y errors
. Las dos primeras funciones son lo que Kent C. Dodds llama "captadores de accesorios" (consulte aquí un excelente artículo sobre ellos), y se utilizan para obtener los accesorios relevantes para un campo de formulario o etiqueta de formulario determinado. La prop de errors
es un objeto con cualquier mensaje de error, codificado por campo.
Este uso se vería así:
const config = { ... }; // like above const LoginForm = props => { const { getFieldProps, getFormProps, errors } = useValidation(config); return ( <form {...getFormProps()}> <label> Username<br/> <input {...getFieldProps('username')} /> {errors.username && <div className="error">{errors.username}</div>} </label> <label> Password<br/> <input {...getFieldProps('password')} /> {errors.password && <div className="error">{errors.password}</div>} </label> <button type="submit">Submit my form</button> </form> ); };
¡Muy bien! Así que hemos clavado la API.
- Ver demostración de CodeSandbox
Tenga en cuenta que también hemos creado una implementación simulada del gancho useValidation
. Por ahora, solo devuelve un objeto con los objetos y funciones que necesitamos para estar allí, por lo que no rompemos nuestra implementación de muestra.
Almacenamiento del estado del formulario
Lo primero que debemos hacer es almacenar todo el estado del formulario en nuestro enlace personalizado. Necesitamos recordar los valores de cada campo, cualquier mensaje de error y si el formulario ha sido enviado o no. Usaremos el useReducer
para esto, ya que permite la mayor flexibilidad (y menos repetitivo). Si alguna vez ha usado Redux, verá algunos conceptos familiares, y si no, ¡lo explicaremos a medida que avanzamos! Comenzaremos escribiendo un reductor, que se pasa al gancho useReducer
:
const initialState = { values: {}, errors: {}, submitted: false, }; function validationReducer(state, action) { switch(action.type) { case 'change': const values = { ...state.values, ...action.payload }; return { ...state, values, }; case 'submit': return { ...state, submitted: true }; default: throw new Error('Unknown action type'); } }
¿Qué es un reductor?
Un reductor es una función que acepta un objeto de valores y una "acción" y devuelve una versión aumentada del objeto de valores.
Las acciones son objetos simples de JavaScript con una propiedad de type
. Estamos usando una declaración de switch
para manejar cada tipo de acción posible.
El "objeto de valores" a menudo se denomina estado y, en nuestro caso, es el estado de nuestra lógica de validación.
Nuestro estado consta de tres datos: values
(los valores actuales de los campos de nuestro formulario), errors
(el conjunto actual de mensajes de error) y un indicador isSubmitted
que indica si nuestro formulario se ha enviado o no al menos una vez.
Para almacenar el estado de nuestro formulario, necesitamos implementar algunas partes de nuestro useValidation
. Cuando llamamos a nuestro método getFieldProps
, necesitamos devolver un objeto con el valor de ese campo, un controlador de cambios para cuando cambia y un accesorio de nombre para rastrear qué campo es cuál.
function validationReducer(state, action) { // Like above } const initialState = { /* like above */ }; const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); return { errors: state.errors, getFormProps: e => {}, getFieldProps: fieldName => ({ onChange: e => { if (!config.fields[fieldName]) { return; } dispatch({ type: 'change', payload: { [fieldName]: e.target.value } }); }, name: fieldName, value: state.values[fieldName], }), }; };
El método getFieldProps
ahora devuelve los accesorios requeridos para cada campo. Cuando se activa un evento de cambio, nos aseguramos de que el campo esté en nuestra configuración de validación y luego le decimos a nuestro reductor que se llevó a cabo una acción de change
. El reductor manejará los cambios en el estado de validación.
- Ver demostración de CodeSandbox
Validando Nuestro Formulario
Nuestra biblioteca de validación de formularios se ve bien, ¡pero no está haciendo mucho en términos de validar los valores de nuestros formularios! Arreglemos eso.
Vamos a validar todos los campos en cada evento de cambio. Esto puede no parecer muy eficiente, pero en las aplicaciones del mundo real que he encontrado, no es realmente un problema.
Tenga en cuenta que no estamos diciendo que tenga que mostrar todos los errores en cada cambio. Más adelante en este artículo, revisaremos cómo mostrar errores solo cuando envía o navega fuera de un campo.
Cómo elegir funciones de validación
Cuando se trata de validadores, existen toneladas de bibliotecas que implementan todos los métodos de validación que pueda necesitar. También puedes escribir el tuyo propio si quieres. ¡Es un ejercicio divertido!
Para este proyecto, vamos a utilizar un conjunto de validadores que escribí hace algún tiempo: calidators
. Estos validadores tienen la siguiente API:
function isRequired(config) { return function(value) { if (value === '') { return config.message; } else { return null; } }; } // or the same, but terser const isRequired = config => value => value === '' ? config.message : null;
En otras palabras, cada validador acepta un objeto de configuración y devuelve un validador completamente configurado. Cuando se llama a esa función con un valor, devuelve el message
prop si el valor no es válido o null
si es válido. Puede ver cómo se implementan algunos de estos validadores mirando el código fuente.
Para acceder a estos validadores, instale el paquete calidators
con npm install calidators
.
Validar un solo campo
¿Recuerdas la configuración que le pasamos a nuestro objeto useValidation
? Se parece a esto:
{ fields: { username: { isRequired: { message: 'Please fill out a username' }, }, password: { isRequired: { message: 'Please fill out a password' }, isMinLength: { value: 6, message: 'Please make it more secure' } } }, // more stuff }
Para simplificar nuestra implementación, supongamos que solo tenemos un campo para validar. Revisaremos cada clave del objeto de configuración del campo y ejecutaremos los validadores uno por uno hasta que encontremos un error o terminemos de validar.
import * as validators from 'calidators'; function validateField(fieldValue = '', fieldConfig) { for (let validatorName in fieldConfig) { const validatorConfig = fieldConfig[validatorName]; const validator = validators[validatorName]; const configuredValidator = validator(validatorConfig); const errorMessage = configuredValidator(fieldValue); if (errorMessage) { return errorMessage; } } return null; }
Aquí, hemos escrito una función validateField
, que acepta el valor para validar y las configuraciones del validador para ese campo. Recorremos todos los validadores, les pasamos la configuración para ese validador y lo ejecutamos. Si nos sale un mensaje de error, nos saltamos el resto de validadores y volvemos. Si no, probamos con el siguiente validador.
Nota: en las API de validación
Si elige diferentes validadores con diferentes API (como el muy popular validator.js
), esta parte de su código puede verse un poco diferente. Sin embargo, en aras de la brevedad, dejamos que esa parte sea un ejercicio para el lector.
Nota: Activado para... en bucles
¿Nunca se usó for...in
bucles antes? Eso está bien, esta fue mi primera vez también! Básicamente, itera sobre las claves en un objeto. Puedes leer más sobre ellos en MDN.
Validar todos los campos
Ahora que hemos validado un campo, deberíamos poder validar todos los campos sin demasiados problemas.
function validateField(fieldValue = '', fieldConfig) { // as before } function validateFields(fieldValues, fieldConfigs) { const errors = {}; for (let fieldName in fieldConfigs) { const fieldConfig = fieldConfigs[fieldName]; const fieldValue = fieldValues[fieldName]; errors[fieldName] = validateField(fieldValue, fieldConfig); } return errors; }
Hemos escrito una función validateFields
campos que acepta todos los valores de campo y la configuración de campo completa. Recorremos cada nombre de campo en la configuración y validamos ese campo con su objeto y valor de configuración.
Siguiente: Dile a nuestro reductor
Muy bien, ahora tenemos esta función que valida todas nuestras cosas. ¡Vamos a incluirlo en el resto de nuestro código!
Primero, vamos a agregar un controlador de acción de validate
a nuestro validationReducer
.
function validationReducer(state, action) { switch (action.type) { case 'change': // as before case 'submit': // as before case 'validate': return { ...state, errors: action.payload }; default: throw new Error('Unknown action type'); } }
Cada vez que activamos la acción de validate
, reemplazamos los errores en nuestro estado con lo que se pasó junto con la acción.
A continuación, activaremos nuestra lógica de validación desde un useEffect
:
const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); useEffect(() => { const errors = validateFields(state.fields, config.fields); dispatch({ type: 'validate', payload: errors }); }, [state.fields, config.fields]); return { // as before }; };
Este useEffect
se ejecuta cada vez que cambian nuestros state.fields
o config.fields
, además del primer montaje.
Cuidado con el error
Hay un error súper sutil en el código anterior. Hemos especificado que nuestro useEffect
solo debe volver a ejecutarse cada vez que state.fields
o config.fields
. ¡Resulta que "cambio" no significa necesariamente un cambio en el valor! useEffect
usa Object.is
para asegurar la igualdad entre los objetos, que a su vez usa la igualdad de referencia. Es decir, si pasa un objeto nuevo con el mismo contenido, no será el mismo (ya que el objeto en sí es nuevo).
Los state.fields
se devuelven desde useReducer
, lo que nos garantiza esta igualdad de referencia, pero nuestra config
se especifica en línea en nuestro componente de función. Eso significa que el objeto se vuelve a crear en cada renderizado, lo que a su vez activará el useEffect
anterior.
Para resolver esto, necesitamos usar la biblioteca use-deep-compare-effect
de Kent C. Dodds. Lo instala con npm install use-deep-compare-effect
, y reemplaza su llamada useEffect
con esto en su lugar. Esto asegura que hagamos una verificación de igualdad profunda en lugar de una verificación de igualdad de referencia.
Su código ahora se verá así:
import useDeepCompareEffect from 'use-deep-compare-effect'; const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); useDeepCompareEffect(() => { const errors = validateFields(state.fields, config.fields); dispatch({ type: 'validate', payload: errors }); }, [state.fields, config.fields]); return { // as before }; };
Una nota sobre el efecto de uso
Resulta que useEffect
es una función bastante interesante. Dan Abramov escribió un artículo muy bueno y largo sobre las complejidades de useEffect
si está interesado en aprender todo lo que hay sobre este gancho.
¡Ahora las cosas empiezan a parecerse a una biblioteca de validación!
- Ver demostración de CodeSandbox
Manejo del envío de formularios
La pieza final de nuestra biblioteca básica de validación de formularios es manejar lo que sucede cuando enviamos el formulario. En este momento, recarga la página, y no pasa nada. Eso no es óptimo. Queremos evitar el comportamiento predeterminado del navegador cuando se trata de formularios y, en cambio, manejarlo nosotros mismos. Colocamos esta lógica dentro de la función captadora de getFormProps
:
const useValidation = config => { const [state, dispatch] = useReducer(validationReducer, initialState); // as before return { getFormProps: () => ({ onSubmit: e => { e.preventDefault(); dispatch({ type: 'submit' }); if (config.onSubmit) { config.onSubmit(state); } }, }), // as before }; };
Cambiamos nuestra función getFormProps
para devolver una función onSubmit
, que se activa cada vez que se activa el evento DOM de submit
. Prevenimos el comportamiento predeterminado del navegador, enviamos una acción para decirle a nuestro reductor que enviamos y llamamos a la devolución de llamada onSubmit
proporcionada con todo el estado, si se proporciona.
Resumen
¡Estaban allí! Hemos creado una biblioteca de validación simple, usable y bastante buena. Sin embargo, todavía queda mucho trabajo por hacer antes de que podamos dominar las interwebs.
- Parte 1: Los fundamentos
- Parte 2: Las características
- Parte 3: La Experiencia