Simplificando a criação de formulários no ReactJS usando Context e sem bibliotecas. (TS)

Olá pessoal, criei uma pequena estratégia de código pra simplificar a criação de formulários no react usando basicamente a ContextAPI do react. Lembrando que essa estrutura não é a solução perfeita (muito longe disso inclusive) numa aplicação mais robusta com certeza o ideal é utilizar alguma biblioteca madura e segura, mas para pequenos projetos essa estrutura pode ser bem interessante e agilizar muita coisa, sem mais delongas, vamos ao código.

OBS: Este post não teve nenhum contato com IA alguma, por isso, caso encontre algum erro de ortografia absurdo, ignore por favor (estou com sono).

Estrutura básica

Vamos começar criando uma pasta chamada Form (geralmente dentro de uma pasta onde fica os componentes da aplicação, como por exemplo: src/components/Form). dentro dela criaremos um index.tsx que iremos utilizar como atalho para os componentes de formulário:

// index.tsx
export const Form = {}

Agora vamos iniciar nosso arquivo de contexto:

// Context.ts
export const FormContext = createContext(null) // temporariamente null

O container principal (onde ficará a tag form):

// Container.tsx
import React, { FormHTMLAttributes, useState, FormEventHandler } from 'react'

const FormContainer: React.FC<FormHTMLAttributes<HTMLFormElement>> = (properties) => {
    const [data, setData] = useState({})
    
    const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
        event.preventDefault()
    }
    
    return <form {...properties} onSubmit={handleSubmit} />
}

export default FormContainer

O nosso template de input:

// Input.tsx
import React, { InputHTMLAttributes } from 'react'

interface InputProperties extends InputHTMLAttributes<HTMLInputElement> {
    label: string
    id: string
}

const FormInput: React.FC<InputProperties> = ({ label, ...properties }) => {
    return (
        <div>
            <label htmlFor={properties.id}>{label}:</label>
            <input {...properties} />
        </div>
    )
}

export default FormInput

Particulamente eu gosto dessa estrutura de input onde eu já tenho um label, e por fim, antes de começar a juntar as coisas, vamos criar um arquivo para nossas interfaces que irão padronizar a conversa entre cada um dos componentes:

// interfaces.ts
import { ChangeEventHandler } from 'react'

export type InputChangeHandler = ChangeEventHandler<HTMLInputElement>

Conectando as partes

Agora finalmente vamos começar a conectar essas peças para que se comuniquem e agilizem nosso trabalho, essas são as etapas que o preenchimento de um formulário geralmente tem:

  1. Usuário digita num input, este capta a alteração e salva esses dados num estado através de um handler.
  2. Após o preenchimento de todos os inputs, o usuário clica num botão de submit fazendo o form disparar o evento (que geralmente previnimos o comportamento padrão com um outro handler para evitar o reload).
  3. Utilizamos o handler do form para recuperar os dados do estado e enviar para alguma API.

Como é possível observar, criamos um estado que será o estado global do formulário no arquivo Container.tsx:

// Container.tsx
// ...
const [data, setData] = useState({})
// ...

Nosso objetivo é fazer com que nossos inputs atualizem esse estado. Vamos começar trabalhando no nosso Container.tsx criando uma função genérica que consegue pegar o evento de um input qualquer e setar o valor dele no estado global do formulário aproveitando que definimos o atributo id como obrigatório no nosso Input.tsx:

// Container.tsx
// ...
import { InputChangeHandler } from './interfaces'
// ...
const [data, setData] = useState({})

const inputChangeHandler: InputChangeHandler = (event) => {
    const { id, value } = event.target
    setData({ ...data, [id]: value })
}
// ...

Agora que temos nossa função que irá atualizar nosso estado, precisamos deixa-la disponível para que inputs do nosso formulário possam utiliza-la. Faremos isso tornando essa função parte do nosso contexto:

// Context.ts
import { createContext } from 'react'
import { InputChangeHandler } from './interfaces'

interface FormContextProperties {
    inputChangeHandler: InputChangeHandler
}

export const FormContext = createContext<FormContextProperties>({
    inputChangeHandler: () => undefined, // valor inicial que será substituído
})

Nosso contexto está pronto e agora vamos adicionar o provider dele no nosso Container:

// Container.tsx
import { FormContext } from './Context'
// ...
return (
    <FormContext.Provider value={{ inputChangeHandler }}>
        <form {...properties} onSubmit={handleSubmit} />
    </FormContext.Provider>
)
// ...

Agora nossos inputs que estiverem dentro desse provider, poderão usar esse contexto para recuperar o handler que tratará os eventos de alterações (onChange):

// Input.tsx
import React, { InputHTMLAttributes } from 'react'
import { FormContext } from './Context'

interface InputProperties extends InputHTMLAttributes<HTMLInputElement> {
    label: string
    id: string
}

const FormInput: React.FC<InputProperties> = ({ label, ...properties }) => {
    const formContext = useContext(FormContext)
    
    return (
        <div>
            <label htmlFor={properties.id}>{label}:</label>
            <input onChange={formContext.inputChangeHandler} {...properties} />
        </div>
    )
}

export default FormInput

Com isso, finalizamos nosso componente de input, todos inputs criados com ele irão atualizar o estado global do formulário. Agora vamos lidar com a parte de submit, criando uma função que retorna nossos dados. Vamos começar criando uma interface para essa função:

// interfaces.ts
import { ChangeEventHandler } from 'react'

export type InputChangeHandler = ChangeEventHandler<HTMLInputElement>
export type FormDataHandler = (form: Record<string, string>) => void

Agora vamos implementar essa interface no nosso container:

// Container.tsx
// ...
import { FormDataHandler, InputChangeHandler } from './interfaces'

interface FormContainerProperties extends FormHTMLAttributes<HTMLFormElement> {
  formData: FormDataHandler
}

const FormContainer: React.FC<FormContainerProperties> = ({ formData, ...properties }) => {
    // ...
    const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
        event.preventDefault()
        formData(form)
    }
// ...

E para finalizar, vamos expor de forma simplificada as partes principais no nosso index:

// index.tsx
import FormContainer from './Container'
import FormInput from './Input'

export type { FormDataHandler } from './interfaces'

export const Form = {
  Container: FormContainer,
  Input: FormInput,
}

Considerações finais e como utilizar

Primeiramente, se você chegou até aqui, muito obrigado pela sua leitura, e você pode estar se perguntando "será mesmo que compensa tudo isso?" e nada melhor do que comparar uma estrutura tradicional à essa estrutura para validarmos:

// formulário tradicional
import React, { useState } from 'react'

const Form: React.FC = () => {
    const [data, setData] = useState({
        email: '',
        senha: ''
    })
    
    const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
        event.preventDefault()
        // envio do estado 'data' para a API
    }
    
    return (
        <form onSubmit={handleSubmit}>
            <input
                type="email"
                required
                value={data.email}
                onChange={(e) => setData({ ...data, email: e.target.value })}
            />
            <input
                type="password"
                minLength={8}
                required
                value={data.email}
                onChange={(e) => setData({ ...data, password: e.target.value })}
            />

            <button type="submit">login</button>
        </form>
    )
}

export default Form

Agora um exemplo de como usar o componente criado nesse post para criar formulários:

// form.tsx
import React from 'react'
import { Form, FormDataHandler } from './components/Form'

const Form: React.FC = () => {
  const formDataHandler: FormDataHandler = (data) => {
    // envio do estado 'data' para a API
  }

  return (
    <Form.Container formData={formDataHandler}>
      <Form.Input label="E-mail" id="email" type="email" required />
      <Form.Input label="Password" id="password" type="password" minLength={8} required />
      <button type="submit">login</button>
    </Form.Container>
  )
}

export default Form

A grande diferença é que toda a parte de estado já fica implementada automáticamente para cada formulário que criármos, dispensando useState, value={...} e onChange={(e) => ...}. Podemos inclusive juntar todo este código num unico arquivo para facilitar replicar essa estrutura em diversos projetos.

Disclaimers

Reafirmo que essa estrutura não é a solução perfeita (muito muito longe disso inclusive) numa aplicação mais robusta o ideal é utilizar alguma biblioteca madura e segura, mas para pequenos projetos essa estrutura pode agilizar as coisas e ATENÇÃO, ela possui alguns pontos importantes que podem causar erros:

  1. Seu input fica escravo de um formulário, em casos como campos de busca que não necessáriamente vai ter um submit, seu input vai dar erro por não encontrar um contexto. Isso é facilmente resolvível extraindo seu input puro para fora do FormInput e utilizar o FormInput somente como wraper dele.
  2. Não sei exatamente por que alguem faria isso, mas não sei o que pode acontecer caso tente adicionar um formulário dentro de outro formulário, não sei como o React iria lidar com esse contexto duplicado (talvez utilizar o mais próximo do input, não sei).
  3. Também não sei pq alguem faria isso, mas como vimos, o atributo ID do input é utilizado como chave para armazenar o valor de cada input no estado, então criar inputs com IDs duplicados farão com que o estado de um input sobrescreva o do outro...

Caso decida testar essa estrutura e encontre novos problemas, compartilhe! Ficaria muito grato adicionar sua contribuição ao post. A todos que chegaram até aqui, meu muito obrigado e por favor, me adicione no GitHub e LinkedIn.

Pelo pouco que sei de React (sou fullstack há 3 anos), achei essa solução bem ruim na questão de performance. Digo isso por salvar o state em um objeto, o que faz com que o React tenha mais dificuldade de fazer diff em formulários grandes. Até onde eu sei, a melhor forma seria uma lib própria pra isso, como o react-hook-form.

Por favor, me corrijam se eu estiver errado

Sim, como ele disse, pra uma pagina de login é uma otima solução, porem sem voce vai fazer um formulario grande em outro lugar e ja ta utilizando uma biblioteca, é uma boa ja implementar em tudo que é lugar kkk, porem como aprendizado é uma ótima estrutura, aprender o conceito de context e uso de estados.
Com toda certeza, por isso que na ultima sessão do post eu escrevi: > Claro que essa estrutura não é a solução perfeita (muito longe disso inclusive) numa aplicação mais robusta com certeza o ideal é utilizar alguma **biblioteca madura e segura**, mas para **pequenos** projetos essa estrutura pode ser bem interessante e agilizar muita coisa O objetivo dessa estratégia não é ser melhor que um biblioteca, é resolver pequenos casos e evitar overengineering. Atualizei o post colocando essa observação já no inicio.

Ver as soluções "simples" do React para coisas triviais como gerenciamento de forms é o que me faz não ter um pingo de arrependimento de ter trocado a stack que trabalho para Htmx e AlpineJS. E incrível o quão pouco vc precisa escrever para resolver os problemas mais comuns do Frontend. Uma pena que React e companhia tenha distorcido essa visão. Recomendo a todos que tiverem curiosidade ou forem céticos sobre o assunto a experimentar a utilização de bibliotecas do tipo com qualquer linguagem que ofereça um bom framework web.

PS: Essa não é uma crítica ao post, que descreve uma solução viável na ferramenta que se propõe a usar. Muito menos uma crítica pessoal a escolha de tal ferramenta. É apenas uma opinião pessoal de o por que não a uso mais.