Criando sua própria biblioteca de validação do React: O básico (Parte 1)
Publicados: 2022-03-10Sempre achei as bibliotecas de validação de formulários muito legais. Eu sei, é um interesse de nicho – mas nós os usamos muito! Pelo menos no meu trabalho – a maior parte do que faço é construir formulários mais ou menos complexos com regras de validação que dependem de escolhas e caminhos anteriores. Entender como uma biblioteca de validação de formulário funcionaria é fundamental.
No ano passado, escrevi uma dessas bibliotecas de validação de formulários. Eu o chamei de “Calidation”, e você pode ler o post introdutório do blog aqui. É uma boa biblioteca que oferece muita flexibilidade e usa uma abordagem um pouco diferente das outras do mercado. Existem muitas outras ótimas bibliotecas por aí também – a minha funcionou bem para nossos requisitos.
Hoje, vou mostrar como escrever sua própria biblioteca de validação para React. Passaremos pelo processo passo a passo e você encontrará exemplos do CodeSandbox à medida que avançamos. Ao final deste artigo, você saberá como escrever sua própria biblioteca de validação, ou pelo menos terá uma compreensão mais profunda de como outras bibliotecas implementam “a mágica da validação”.
- Parte 1: O básico
- Parte 2: Os Recursos
- Parte 3: A Experiência
Etapa 1: projetar a API
A primeira etapa da criação de qualquer biblioteca é projetar como ela será usada. Ele estabelece as bases para grande parte do trabalho que está por vir e, na minha opinião, é a decisão mais importante que você tomará em sua biblioteca.
É importante criar uma API que seja “fácil de usar” e ainda flexível o suficiente para permitir futuras melhorias e casos de uso avançados. Vamos tentar atingir esses dois objetivos.
Vamos criar um gancho personalizado que aceitará um único objeto de configuração. Isso permitirá que opções futuras sejam aprovadas sem introduzir alterações significativas.
Uma nota sobre ganchos
Hooks é uma maneira bem nova de escrever React. Se você já escreveu React no passado, talvez não reconheça alguns desses conceitos. Nesse caso, consulte a documentação oficial. É incrivelmente bem escrito e leva você através do básico que você precisa saber.
Vamos chamar nosso hook customizado de useValidation
por enquanto. Seu uso pode ser algo assim:
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);
O objeto de config
aceita uma prop fields
, que configura as regras de validação para cada campo. Além disso, ele aceita um retorno de chamada para quando o formulário for enviado.
O objeto fields
contém uma chave para cada campo que queremos validar. Cada campo tem sua própria configuração, onde cada chave é um nome de validador e cada valor é uma propriedade de configuração para aquele validador. Outra maneira de escrever o mesmo seria:
{ fields: { fieldName: { oneValidator: { validatorRule: 'validator value' }, anotherValidator: { errorMessage: 'something is not as it should' } } } }
Nosso hook useValidation
retornará um objeto com algumas propriedades — getFieldProps
, getFormProps
e errors
. As duas primeiras funções são o que Kent C. Dodds chama de “prop getters” (veja aqui para um ótimo artigo sobre isso), e é usado para obter as props relevantes para um determinado campo de formulário ou tag de formulário. A prop errors
é um objeto com qualquer mensagem de erro, digitada por campo.
Esse uso ficaria assim:
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> ); };
Tudo bem! Então, acertamos a API.
- Veja a demonstração do CodeSandbox
Observe que também criamos uma implementação simulada do gancho useValidation
. Por enquanto, está apenas retornando um objeto com os objetos e funções que precisamos para estar lá, então não interrompemos nossa implementação de exemplo.
Armazenando o estado do formulário
A primeira coisa que precisamos fazer é armazenar todo o estado do formulário em nosso gancho personalizado. Precisamos lembrar os valores de cada campo, quaisquer mensagens de erro e se o formulário foi enviado ou não. Usaremos o gancho useReducer
para isso, pois permite maior flexibilidade (e menos clichê). Se você já usou o Redux, verá alguns conceitos familiares - e se não, explicaremos à medida que avançamos! Começaremos escrevendo um redutor, que é passado para o 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'); } }
O que é um redutor?
Um redutor é uma função que aceita um objeto de valores e uma “ação” e retorna uma versão aumentada do objeto de valores.
As ações são objetos JavaScript simples com uma propriedade de type
. Estamos usando uma instrução switch
para lidar com cada tipo de ação possível.
O “objeto de valores” é muitas vezes referido como estado e, no nosso caso, é o estado da nossa lógica de validação.
Nosso estado consiste em três partes de dados — values
(os valores atuais de nossos campos de formulário), errors
(o conjunto atual de mensagens de erro) e um sinalizador isSubmitted
indicando se nosso formulário foi enviado ou não pelo menos uma vez.
Para armazenar nosso estado de formulário, precisamos implementar algumas partes do nosso hook useValidation
. Quando chamamos nosso método getFieldProps
, precisamos retornar um objeto com o valor desse campo, um manipulador de alterações para quando ele for alterado e uma propriedade de nome para rastrear qual campo é qual.
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], }), }; };
O método getFieldProps
agora retorna as props necessárias para cada campo. Quando um evento de alteração é acionado, garantimos que o campo esteja em nossa configuração de validação e, em seguida, informamos ao nosso redutor que uma ação de change
ocorreu. O redutor tratará das alterações no estado de validação.
- Veja a demonstração do CodeSandbox
Validando nosso formulário
Nossa biblioteca de validação de formulário parece boa, mas não está fazendo muito em termos de validação de nossos valores de formulário! Vamos consertar isso.
Vamos validar todos os campos em cada evento de mudança. Isso pode não parecer muito eficiente, mas nos aplicativos do mundo real que encontrei, não é realmente um problema.
Observe que não estamos dizendo que você precisa mostrar todos os erros em todas as alterações. Revisitaremos como mostrar erros somente quando você enviar ou sair de um campo, mais adiante neste artigo.
Como escolher as funções do validador
Quando se trata de validadores, existem várias bibliotecas por aí que implementam todos os métodos de validação que você precisa. Você também pode escrever o seu próprio, se quiser. É um exercício divertido!
Para este projeto, vamos usar um conjunto de validadores que escrevi há algum tempo — calidators
. Esses validadores têm a seguinte 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;
Em outras palavras, cada validador aceita um objeto de configuração e retorna um validador totalmente configurado. Quando essa função é chamada com um valor, ela retorna a message
prop se o valor for inválido, ou null
se for válido. Você pode ver como alguns desses validadores são implementados observando o código-fonte.
Para acessar esses validadores, instale o pacote calidators
com npm install calidators
.
Validar um único campo
Lembra da configuração que passamos para o nosso objeto useValidation
? Se parece com isso:
{ 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 nossa implementação, vamos supor que temos apenas um único campo para validar. Passaremos por cada chave do objeto de configuração do campo e executaremos os validadores um por um até encontrarmos um erro ou terminarmos 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; }
Aqui, escrevemos uma função validateField
, que aceita o valor a ser validado e as configurações do validador para esse campo. Percorremos todos os validadores, passamos a configuração para esse validador e o executamos. Se recebermos uma mensagem de erro, pulamos o restante dos validadores e retornamos. Caso contrário, tentamos o próximo validador.
Observação: em APIs de validação
Se você escolher validadores diferentes com APIs diferentes (como o muito popular validator.js
), essa parte do seu código pode parecer um pouco diferente. Por uma questão de brevidade, no entanto, deixamos essa parte ser um exercício deixado para o leitor.
Nota: On for…in loops
Nunca usado for...in
loops antes? Isso é bom, esta foi a minha primeira vez também! Basicamente, ele itera sobre as chaves em um objeto. Você pode ler mais sobre eles no MDN.
Valide todos os campos
Agora que validamos um campo, devemos ser capazes de validar todos os campos sem muita dificuldade.
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; }
Escrevemos uma função validateFields
que aceita todos os valores de campo e toda a configuração de campo. Percorremos cada nome de campo na configuração e validamos esse campo com seu objeto e valor de configuração.
Próximo: Diga ao nosso redutor
Tudo bem, agora temos essa função que valida todas as nossas coisas. Vamos puxá-lo para o resto do nosso código!
Primeiro, vamos adicionar um manipulador de ação de validate
ao nosso 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'); } }
Sempre que acionamos a ação de validate
, substituímos os erros em nosso estado pelo que foi passado junto com a ação.
Em seguida, vamos acionar nossa lógica de validação de um gancho 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 gancho useEffect
é executado sempre que nosso state.fields
ou config.fields
muda, além da primeira montagem.
Cuidado com o Bug
Há um bug super sutil no código acima. Especificamos que nosso hook useEffect
só deve ser executado novamente sempre que state.fields
ou config.fields
mudarem. Acontece que “mudança” não significa necessariamente uma mudança de valor! useEffect
usa Object.is
para garantir a igualdade entre objetos, que por sua vez usa igualdade de referência. Ou seja — se você passar um novo objeto com o mesmo conteúdo, ele não será o mesmo (já que o próprio objeto é novo).
Os state.fields
são retornados de useReducer
, o que nos garante essa igualdade de referência, mas nossa config
é especificada inline em nosso componente de função. Isso significa que o objeto é recriado em cada renderização, o que, por sua vez, acionará o useEffect
acima!
Para resolver isso, precisamos usar a biblioteca use-deep-compare-effect
de Kent C. Dodds. Você o instala com npm install use-deep-compare-effect
e substitui sua chamada useEffect
por isso. Isso garante que façamos uma verificação de igualdade profunda em vez de uma verificação de igualdade de referência.
Seu código agora ficará assim:
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 }; };
Uma nota sobre useEffect
Acontece que useEffect
é uma função bastante interessante. Dan Abramov escreveu um artigo muito bom e longo sobre os meandros do useEffect
se você estiver interessado em aprender tudo sobre esse gancho.
Agora as coisas estão começando a parecer uma biblioteca de validação!
- Veja a demonstração do CodeSandbox
Tratamento do envio do formulário
A parte final da nossa biblioteca básica de validação de formulários é lidar com o que acontece quando enviamos o formulário. No momento, ele recarrega a página e nada acontece. Isso não é o ideal. Queremos evitar o comportamento padrão do navegador quando se trata de formulários e lidar com isso nós mesmos. Colocamos essa lógica dentro da função getFormProps
prop getter:
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 }; };
Alteramos nossa função getFormProps
para retornar uma função onSubmit
, que é acionada sempre que o evento submit
DOM é acionado. Impedimos o comportamento padrão do navegador, despachamos uma ação para informar ao nosso redutor que enviamos e chamamos o retorno de chamada onSubmit
fornecido com todo o estado — se for fornecido.
Resumo
Estavam lá! Criamos uma biblioteca de validação simples, utilizável e bem legal. Ainda há muito trabalho a ser feito antes que possamos dominar as interwebs.
- Parte 1: O básico
- Parte 2: Os Recursos
- Parte 3: A Experiência