Executando verificação de segurança...
2

[React] Como criar formulários dinâmicos - com uma surpresa no final

Input Progressivos

Se você já precisou fazer um formulário que alimenta algo de maneira imprevisível e com conteúdos distintos -- ou seja, que pode ter um título, uma galeria, uma imagem e um embed em uma ordem e quantidade que o usuário vai escolher, você sabe do que estou falando.

Eu precisei desenvolver um desse nas últimas semanas, e vou compartilhar com vocês como reproduzir ele.

Ah, e no final deixei uma supresinha que fará com que seu formulário dinâmico suba pra outro nível, e de forma bem simples.

Nossos Objetivos

Listando em ordem o que vamos implementar:

  1. Criar os componentes de input
  2. Definir o estado inicial do formulário
  3. Renderizar a partir de um estado(useState)
  4. Atualizar o estado em cada modificação

1 - Criar os componentes do input

Na criação deles é preciso se atentar a dois elementos:

  • Key
  • Atributo Único

O primeiro é necessário para o React sabe que esse input não é igual a outro com a mesma estrutura no momento da renderização e atualização,
o segundo é para o nosso código acessar e distinguir com certa facilidade os inputs que nós adicionarmos.

Para não exaurir de exemplos, deixaria quatro amostras do que eu utilizei:

    // Input 1 - Título
    <div className={Style.inputContainer} key={1} listid={1}>
        <label className={Style.labelInput} htmlFor="title">Título</label>
        <input className={Style.input} type="text" name="title" onChange={handleInputChange} />
    </div>
    
    // Input 2 - Legenda
    <div className={Style.inputContainer} key={2} listid={2}>
        <label className={Style.labelInput} htmlFor="subtitle">Legenda</label>
        <textarea type="text" name="subtitle" onChange={handleInputChange} />
    </div>
    
    // Input 3 - Embed Link
    <div className={Style.inputContainer} key={3} listid={3}>
        <label className={Style.labelInput} htmlFor="embed">Embed Link</label>
        <input className={Style.input} type="text" name="embed" placeholder='https://my.matterport.com/show/?m=something' onChange={handleInputChange} />
    </div>
    
	// Input 4 - Categoria
    <div className={Style.inputContainer} key={5} listid={5}>
        <label className={Style.labelInput} htmlFor="category">Categoria</label>
        <select onChange={handleInputChange} name="category"  id="category">
            <option value="0">Selecione a categoria...</option>
            <option value="1">Governamental</option>
            <option value="2">Militar</option>
            <option value="3">Igreja</option>
            <option value="4">Museu</option>
            <option value="5">Teatro</option>
            <option value="6">Biblioteca</option>
            <option value="7">Histórico</option>
        </select>
    </div>
    
    // Input 5 - Imagem
    <div className={Style.inputContainer} key={input.listid} listid={input.listid}>
    <div className={Style.labelContainer}>
        <label className={Style.labelInput} htmlFor={input.name}>{input.title}</label>
        <span className={Style.removeInput} onClick={((e) => { e.preventDefault(); handleInputRemove(input.listid) })}>X</span>
    </div>
        {
            input.image ?
                <div className={Style.inputImageFlexContainer}>
                    <div className={Style.inputImageContainer}>
                        <Image
                            src={input.image}
                            alt="Picture of the author"
                            layout='fill'
                            objectFit='cover'
                        />
                    </div>
                    <span className={Style.removeImageInput} onClick={((e) => { e.preventDefault(); clearImageUpload(input.listid) })}>X</span>
                </div>
                :
                <div className={Style.imageLabelContainer}>
                    <label htmlFor={`${input.name + input.listid}`} className={Style.uploadImageInput}>Enviar imagem</label>
                    <input className={Style.imageInput} type="file" id={`${input.name + input.listid}`} name={input.name} onChange={handleImageUpload} />
                </div>
        }
	</div>

Obs: eu tô pegando o código que eu utilizei, então vai terá algumas coisinhas no meio dele que são irrelevantes ao que estou mostrando. Peço que relevem, porque bom... se eu for deixar perfeito, eu não vou postar.

Esses são os inputs que vou utilizar na demonstração. A única coisa que importa neles são o seguinte: reparem que todos possuem uma key e um atributo único que chamei de listid.

2 - Definir o estado inicial do formulário

Quando criamos um formulário dinâmico, muitas vezes temos um mínimo que precisa ser preenchido, e outros campos que são únicos. Aqui é o momento que você vai definir isso.

Para isso, eu declarei um array de objetos com os campos que já existem, já entreguei os primeiros listid's e setei o estado inicial do listid's.

Assim:

    // Estado inicial do formulário
    const initialStateArray = [{
        title: '', listid: 1, name: 'title'
    },
    {
        subtitle: '', listid: 2, name: 'subtitle'
    },
    {
        embed: '', listid: 3, name: 'embed'
    },
    {
        cover: '', listid: 4, name: 'cover'
    },
    {
        category: '0', listid: 5, name: 'category'
    }]
    
    // Iniciando o listid no número 6, já que
    // eu já entreguei os primeiros 5 para os inputs fixos.
    const [inputId, setInputId] = useState(6);

Atenção: Percebam que cada chave no objeto possui um nome, e esse mesmo nome vira uma outra chave do objeto? Ou seja, se o objeto tem "name: 'category' ", ele também terá "category: '' ". Isso é para quando fomos atualizar o valor dele ser possível usar a seguinte sintaxe:
Object['Object.name'] e modificar o seu valor. Essa parte talvez pudesse ser mais simples que isso, mas como eu fiz assim, vou continuar assim(perdão amigos, mas se eu fosse deixar isso perfeito, eu não teria postado).

3 - Renderizar a partir de um estado(useState)

Até aqui acredito que não teve muita novidade. Mas agora começa a parte legal.

Para renderizar os inputs, nos precisaremos salvar eles em um estado do React(a partir de agora vou chamar de useState).

Como nós já temos o estado inicial, criaremos um useState para os dados do formulário e inicializaremos com o estado que já criamos. Assim:

    // Esse é o estado que salva as informações(inputs e valores)
    // do formulário
    const [formData, setFormData] = useState(initialStateArray);
    
    // Esse aqui segura os valores antes da atualização (Depois
    // eu mostro o motivo de sua existência, essa parte é divertida.)
    const [backupData, setBackupData] = useState([]);

Feito isso, nós podemos já renderizar o formulário a partir desse estado inicial. Nesse exemplo, todos os campos do estado inicial são únicos, então eu já escrevi eles direto no JSX e só vou renderizar os que forem adicionados. Portanto, estou chamando minha função para renderizar assim:

formData.map(input => {
    if (input.listid > 5) {
        if (input.type === 'image') {
            return (
                <div className={Style.inputContainer} key={input.listid} listid={input.listid}>
                    <div className={Style.labelContainer}>
                        <label className={Style.labelInput} htmlFor={input.name}>{input.title}</label>
                        <span className={Style.removeInput} onClick={((e) => { e.preventDefault(); handleInputRemove(input.listid) })}>X</span>
                    </div>
                    {
                        input.image ?
                            <div className={Style.inputImageFlexContainer}>
                                <div className={Style.inputImageContainer}>
                                    <Image
                                        src={input.image}
                                        alt="Picture of the author"
                                        layout='fill'
                                        objectFit='cover'
                                    />
                                </div>
                                <span className={Style.removeImageInput} onClick={((e) => { e.preventDefault(); clearImageUpload(input.listid) })}>X</span>
                            </div>
                            :
                            <div className={Style.imageLabelContainer}>
                                <label htmlFor={`${input.name + input.listid}`} className={Style.uploadImageInput}>Enviar imagem</label>
                                <input className={Style.imageInput} type="file" id={`${input.name + input.listid}`} name={input.name} onChange={handleImageUpload} />
                            </div>
                    }
                </div>
            )
        }
        if (input.type === 'gallery') {
            return (
                <div className={Style.inputContainer} key={input.listid} listid={input.listid}>
                    <div className={Style.labelContainer}>
                        <label className={Style.labelInput} onClick={() => console.log(input)}>{input.title}</label>
                        <span className={Style.removeInput} onClick={((e) => { e.preventDefault(); handleInputRemove(input.listid) })}>X</span>
                    </div>
                    <div className={Style.imageLabelContainer}>
                        <label htmlFor={`${input.name + input.listid}`} className={Style.uploadImageInput}>Adicionar imagem</label>
                        <input className={Style.imageInput} type="file" id={`${input.name + input.listid}`} name={input.name} onChange={handleImageUpload} />
                    </div>
                    <div className={Style.galleryContainer}>
                        {
                            input.gallery.map(url => {
                                return (
                                    <div key={url} className={Style.inputGalleryFlexContainer}>
                                        <div className={Style.inputImageContainer}>
                                            <Image
                                                src={url}
                                                alt="Picture of the author"
                                                layout='fill'
                                                objectFit='cover'
                                            />
                                        </div>
                                        <span className={Style.removeImageInput} onClick={((e) => { e.preventDefault(); clearImageUpload(input.listid, url) })}>X</span>
                                    </div>
                                )
                            })
                        }
                    </div>
                </div>
            )
        }
        return (
            <div className={Style.inputContainer} key={input.listid} listid={input.listid}>
                <div className={Style.labelContainer}>
                    <label className={Style.labelInput} htmlFor={input.name}>{input.title}</label>
                    <span className={Style.removeInput} onClick={((e) => { e.preventDefault(); handleInputRemove(input.listid) })}>X</span>
                </div>
                <textarea type={input.type} name={input.name} defaultValue={!!input[input.name] ? input[input.name] : ''} onChange={handleInputChange} />
            </div>
        )
    }
})

}

Esse Snippet ficou bem grande, mas reparem apenas no seguinte: eu estou renderizando todos os inputs a partir de um estado,ou seja, quando o estado mudar, ele será renderizado novamente, e estou conferindo o nome de cada input antes de renderizar ele, pois um input de imagem tem uma estrutura diferente de uma galeria, que tem uma estrutura diferente dos campos de texto simples.

O que nós temos até aqui?

Basicamente uma estrutura que permite ser alterada manipulando o estado que segura as informações do nosso formulário.

Vamos fazer isso tudo funcionar agora?

4 - Atualizar o estado em cada modificação

Essa vai ser a parte mais longa.

Vamos separar o que nós precisamos fazer:

  1. Atualizar os valores
  2. Adicionar novos inputs
  3. Remover inputs
  4. ?

Atualizar os valores

Para atualizar os valores, nós vamos utilizar a função onChange={handleInputChange} que declarada dessa forma já recebe o evento.

A função dela é a seguinte: pegar o valor modificado do input, identificar quem é ele no estado do nosso formulário e atualizar o estado com um novo valor.

Ela fica assim:

    function handleInputChange(e) {
        // Pega nome e valor do input
        const { name, value } = e.target;
        // Sobe a árvore até encontrar o seu id
        const listid = parseInt(e.target.parentNode.attributes.listid.value)
        
       // Atualiza o estado funcionalmente
        setFormData(prevState => (
            // Faz um loop retornando o todas as informações
            // do formulário, mas quando identifica o objeto
            // com o listid passado, ele atualizar seu valor e
            // depois retorna.
            prevState.map(input => {

                if (input.listid === listid) {
                    input[name] = value
                }
                return input
            })
        ));
    }

Um já foi. Vamos pro próximo:

Adicionar novos inputs

Vocês lembram do estado inicial do array que declaramos lá em cima? Nossas informações do formulário sempre terão esse formato. Então para adicionar um novo item, é só adicionarmos um novo objeto seguindo o mesmo formato.

Desse modo:

    // Essas informações são passadas através de um botão qualquer
    // Que pode ser adicionado na página para chamar essa função.
    // No meu caso, foi o seguinte
    <div onClick={(e) => {handleInputAdd({ name: 'image', title: 'Imagem', type: 'image' }) }}>
        Image
    </div>
    <div onClick={(e) => {handleInputAdd({ name: 'gallery', title: 'Galeria', type: 'gallery', image: [] }) }}>
        Gallery
    </div>
    
    function handleInputAdd(info) {
    // Salvamos o formulário antes das alterações
        setBackupData([...backupData, formData]);
        
        // Pegamos o nome, o título e o tipo do input que vamos adicionar
        const { name, title, type } = info;
        // Checamos o tipo do input, pois a depender do tipo
        // Pode ser necessário passar mais informações para o
        // estado do formulário
        // No meu caso, apenas para a galeria precisar passar
        // informações adicionais, por isso só conferi ela
        // todos os outros funcionam com o formato que tem no else
        // Obs: repare que passamos o listid
        if (type === 'gallery') {
            setFormData([...formData, { gallery: [], listid: inputId, name: name, title: title, type: type }]);

        } else {
            setFormData([...formData, { [name]: '', listid: inputId, name: name, title: title, type: type }]);
        }
        // Por fim, incrementamos o valor do inputid, pois
        // o próximo input não pode ter o mesmo id
        setInputId(inputId + 1);
    }

Remover inputs

Essa parte é importante porque é muito fácil para o usuário adicionar um novo input sem querer. Ou querer mudar a ordem. E sem entregarmos essa flexibilidade pra ele, usar um formulário dinâmico vai ser horrível.

Para isso, nós só precisamos chamar uma função passando o listId, e atualizarmos o estado do nosso formulário removendo o objeto que possui o listid passado.

Assim:

      function handleInputRemove(listid) {
        setBackupData([...backupData, formData]);
        // Basta passando um filter no estado anterior e retornar
        // tudo que não tenho o listid passado
        setFormData(prevState => (
            prevState.filter(input => input.listid !== listid)
        ));
    }

?

Nós criamos a capacidade de remover inputs para facilitar a vida do usuário. Mas... E se ele remover sem querer um input que está bem no meio? Ele vai ter que remover todos que estavam abaixo e depois adicionar e preencher um por um? Temos como fazer melhor que isso.
E foi pra isso que estávamos salvando o backupDate.

Desfazendo mudança nos inputs

Aqui nós temos que simplesmente restaurar o estado do formulário prévio à última adição ou remoção de input. E como nós estávamos salvando esse estado sempre que nós adicionamos ou removemos um input, nós podemos escrever essa função super simples e termos uma feature muito boa. Antes disso, reparem como nós estávamos salvando o backup

setBackupData([...backupData, formData]);

Percebeu? O backupData é um Array com todos os estados que o formulário passou. Ou seja, com uma função simples:

function undoInput() {
        // Conferindo se o backup existe, para não quebrar
        // o formulário caso o usuário aperte antes de ter histórico
        if (backupData.length === 0) return;
        
        // Pegando o último estado do backup
        setFormData(backupData[backupData.length - 1]);
        
        // Removendo o último estado do backup, pois agora
        // ele é o atual
        setBackupData(prevState => (
            prevState.filter(input => input !== backupData[backupData.length - 1])
        ));
        return
    }

Nós demos ao usuário a capacidade de ir desfazendo as mudanças Até ele voltar ao estado do formulário quando a página foi aberta.

Por último, vou só só deixar aqui o código que utilizei para pegar as informações finais e criar o body da requisição de envio de formulário:

const bodyData = {
            title: formData[0].title,
            cover: formData[3].image,
            embed: formData[2].embed,
            subtitle: formData[1].subtitle,
            category: parseInt(formData[4].category),
            content: formData.splice(4, formData.length - 1),
        }
        // Os 4 primeiros itens vão em lugares separados do content
        // Então quando eu declaro os contents, eu uso o método
        // splice para remover os 4 primeiros itens e enviar o resto
        // do array com as informações

Ah, esse código muito provavelmente pode ser melhorado e simplificado, então não se prendam ao formato que escrevi aqui. Peguem apenas a ideia.

Caso tenham alguma dúvida ou sugestão de melhorias, podem mandar!

Espero que tenha sido útil e de alguma compreensão.

Um abraço a você que chegou até aqui!

Carregando publicação patrocinada...