Entendendo Formulários Multi-step em React
Um formulário multi-step, também conhecido como formulário em etapas ou wizard, é um padrão de interface que divide um formulário longo em várias páginas ou etapas menores. Em vez de apresentar todos os campos de uma vez, o usuário passa por etapas sequenciais, preenchendo informações gradualmente. Este padrão melhora a experiência do usuário ao reduzir a cognitiva carga, aumentar a taxa de conclusão e permitir validações parciais.
Em React, implementar um formulário multi-step exige gerenciar três aspectos principais: o estado atual do formulário (qual etapa estamos), os dados preenchidos em cada etapa, e a lógica de navegação entre elas. A abordagem mais comum é usar um componente pai que mantém o estado completo do formulário e renderiza condicionalmente a etapa ativa, enquanto componentes filhos lidam com a exibição de campos específicos.
Por que não é trivial?
Diferentemente de um formulário simples, você precisa decidir: os dados preenchidos devem ser mantidos quando o usuário navega entre etapas? Como validar sem bloquear a navegação para trás? Como gerenciar o estado de forma escalável quando o formulário cresce? Essas questões definem a qualidade da experiência final.
Gerenciamento de Estado
Estruturando o estado do formulário
O estado deve armazenar não apenas os valores dos campos, mas também metadados úteis como erros de validação, estados de toque (se o campo foi visitado) e a etapa atual. Uma estrutura bem organizada facilita depuração, testes e manutenção futura.
const [formData, setFormData] = useState({
// Etapa 1
firstName: '',
lastName: '',
email: '',
// Etapa 2
phone: '',
address: '',
city: '',
// Etapa 3
cardNumber: '',
expiryDate: '',
cvv: ''
});
const [currentStep, setCurrentStep] = useState(1);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
Esta estrutura simples permite que você mantenha todos os dados simultaneamente, evitando perda de informações quando o usuário navega. O objeto touched rastreia quais campos o usuário já interagiu, útil para mostrar erros apenas após o usuário sair do campo.
Atualizando valores de campos
Crie uma função genérica que atualiza o estado do formulário mantendo os valores anteriores. Esta abordagem é escalável e evita repetição de código.
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prevState => ({
...prevState,
[name]: value
}));
};
const handleFieldTouched = (fieldName) => {
setTouched(prevState => ({
...prevState,
[fieldName]: true
}));
};
Quando o usuário digita em um campo, handleInputChange atualiza seu valor. Quando sai do campo (evento onBlur), handleFieldTouched marca-o como tocado. Essa separação permite que você mostre mensagens de erro apenas para campos já visitados, melhorando a experiência.
Validação em Formulários Multi-step
Estratégia de validação por etapa
A validação deve ocorrer em dois momentos: quando o usuário tenta avançar para a próxima etapa e, opcionalmente, em tempo real após o campo ser tocado. Validar apenas na etapa atual evita overhead desnecessário e oferece feedback contextualizado.
const validationRules = {
step1: {
firstName: (value) => {
if (!value.trim()) return 'Nome é obrigatório';
if (value.length < 2) return 'Nome deve ter pelo menos 2 caracteres';
return null;
},
lastName: (value) => {
if (!value.trim()) return 'Sobrenome é obrigatório';
return null;
},
email: (value) => {
if (!value.trim()) return 'E-mail é obrigatório';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) return 'E-mail inválido';
return null;
}
},
step2: {
phone: (value) => {
if (!value.trim()) return 'Telefone é obrigatório';
const phoneRegex = /^[\d\s\-()]{10,}$/;
if (!phoneRegex.test(value)) return 'Telefone inválido';
return null;
},
address: (value) => {
if (!value.trim()) return 'Endereço é obrigatório';
return null;
},
city: (value) => {
if (!value.trim()) return 'Cidade é obrigatória';
return null;
}
},
step3: {
cardNumber: (value) => {
if (!value.trim()) return 'Número do cartão é obrigatório';
const cardRegex = /^[\d\s]{13,19}$/;
if (!cardRegex.test(value)) return 'Número de cartão inválido';
return null;
},
expiryDate: (value) => {
if (!value.trim()) return 'Data de expiração é obrigatória';
const dateRegex = /^(0[1-9]|1[0-2])\/\d{2}$/;
if (!dateRegex.test(value)) return 'Formato: MM/AA';
return null;
},
cvv: (value) => {
if (!value.trim()) return 'CVV é obrigatório';
if (!/^\d{3,4}$/.test(value)) return 'CVV deve ter 3 ou 4 dígitos';
return null;
}
}
};
const validateStep = (stepNumber) => {
const stepKey = `step${stepNumber}`;
const stepFields = validationRules[stepKey];
const newErrors = {};
Object.keys(stepFields).forEach(fieldName => {
const validator = stepFields[fieldName];
const error = validator(formData[fieldName]);
if (error) {
newErrors[fieldName] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
Essa estrutura separa as regras de validação da lógica de aplicação. Cada campo tem uma função que valida seu valor e retorna uma mensagem de erro ou null. A função validateStep aplica todas as regras de uma etapa específica e retorna um booleano indicando sucesso.
Validação em tempo real
Para melhorar a experiência, valide campos individuais enquanto o usuário digita, mas apenas se o campo já foi tocado.
const validateField = (fieldName, value) => {
const fieldValidators = Object.entries(validationRules).reduce(
(acc, [stepKey, fields]) => ({ ...acc, ...fields }),
{}
);
const validator = fieldValidators[fieldName];
return validator ? validator(value) : null;
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prevState => ({
...prevState,
[name]: value
}));
// Validar em tempo real apenas se o campo foi tocado
if (touched[name]) {
const error = validateField(name, value);
setErrors(prevErrors => ({
...prevErrors,
[name]: error
}));
}
};
Agora, à medida que o usuário digita em um campo já visitado, os erros aparecem ou desaparecem em tempo real. Isso oferece feedback imediato sem ser intrusivo para campos ainda não tocados.
Navegação e Fluxo de Etapas
Funções de navegação
Implemente funções que gerenciam a transição entre etapas, validando a etapa atual antes de avançar e permitindo voltar sem validação.
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prevStep => prevStep + 1);
window.scrollTo(0, 0); // Scroll para o topo
}
};
const handlePrevious = () => {
setCurrentStep(prevStep => Math.max(1, prevStep - 1));
window.scrollTo(0, 0);
};
const handleSubmit = (e) => {
e.preventDefault();
// Validar a última etapa também
if (validateStep(currentStep)) {
console.log('Formulário completo:', formData);
// Enviar para servidor, salvar em banco de dados, etc.
}
};
A função handleNext valida a etapa atual usando validateStep. Se há erros, a etapa não muda. A função handlePrevious permite voltar sem validação, preservando dados. O window.scrollTo(0, 0) melhora a experiência rolando para o topo quando a etapa muda.
Componente de renderização condicional
Estruture o componente principal para renderizar diferentes componentes de etapa conforme currentStep muda.
function MultiStepForm() {
// ... estado definido anteriormente ...
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<StepOne
data={formData}
errors={errors}
touched={touched}
onChange={handleInputChange}
onBlur={handleFieldTouched}
/>
);
case 2:
return (
<StepTwo
data={formData}
errors={errors}
touched={touched}
onChange={handleInputChange}
onBlur={handleFieldTouched}
/>
);
case 3:
return (
<StepThree
data={formData}
errors={errors}
touched={touched}
onChange={handleInputChange}
onBlur={handleFieldTouched}
/>
);
default:
return null;
}
};
return (
<div className="form-container">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
<form onSubmit={handleSubmit}>
{renderStep()}
<div className="button-group">
{currentStep > 1 && (
<button
type="button"
onClick={handlePrevious}
className="btn-secondary"
>
Voltar
</button>
)}
{currentStep < 3 ? (
<button
type="button"
onClick={handleNext}
className="btn-primary"
>
Próximo
</button>
) : (
<button
type="submit"
className="btn-success"
>
Enviar
</button>
)}
</div>
</form>
</div>
);
}
Este padrão é claro e escalável. Um switch renderiza diferentes componentes conforme a etapa. Os botões mudam de "Próximo" para "Enviar" na última etapa. A barra de progresso visual melhora a orientação do usuário.
Componentes de etapa individuais
Cada etapa é um componente reutilizável que exibe campos específicos. Mantê-los separados melhora legibilidade e testabilidade.
function StepOne({ data, errors, touched, onChange, onBlur }) {
return (
<div className="step-container">
<h2>Informações Pessoais</h2>
<div className="form-group">
<label htmlFor="firstName">Nome *</label>
<input
id="firstName"
type="text"
name="firstName"
value={data.firstName}
onChange={onChange}
onBlur={() => onBlur('firstName')}
className={errors.firstName && touched.firstName ? 'input-error' : ''}
/>
{errors.firstName && touched.firstName && (
<span className="error-message">{errors.firstName}</span>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">Sobrenome *</label>
<input
id="lastName"
type="text"
name="lastName"
value={data.lastName}
onChange={onChange}
onBlur={() => onBlur('lastName')}
className={errors.lastName && touched.lastName ? 'input-error' : ''}
/>
{errors.lastName && touched.lastName && (
<span className="error-message">{errors.lastName}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">E-mail *</label>
<input
id="email"
type="email"
name="email"
value={data.email}
onChange={onChange}
onBlur={() => onBlur('email')}
className={errors.email && touched.email ? 'input-error' : ''}
/>
{errors.email && touched.email && (
<span className="error-message">{errors.email}</span>
)}
</div>
</div>
);
}
function StepTwo({ data, errors, touched, onChange, onBlur }) {
return (
<div className="step-container">
<h2>Endereço</h2>
<div className="form-group">
<label htmlFor="phone">Telefone *</label>
<input
id="phone"
type="tel"
name="phone"
value={data.phone}
onChange={onChange}
onBlur={() => onBlur('phone')}
className={errors.phone && touched.phone ? 'input-error' : ''}
placeholder="(XX) XXXXX-XXXX"
/>
{errors.phone && touched.phone && (
<span className="error-message">{errors.phone}</span>
)}
</div>
<div className="form-group">
<label htmlFor="address">Endereço *</label>
<input
id="address"
type="text"
name="address"
value={data.address}
onChange={onChange}
onBlur={() => onBlur('address')}
className={errors.address && touched.address ? 'input-error' : ''}
/>
{errors.address && touched.address && (
<span className="error-message">{errors.address}</span>
)}
</div>
<div className="form-group">
<label htmlFor="city">Cidade *</label>
<input
id="city"
type="text"
name="city"
value={data.city}
onChange={onChange}
onBlur={() => onBlur('city')}
className={errors.city && touched.city ? 'input-error' : ''}
/>
{errors.city && touched.city && (
<span className="error-message">{errors.city}</span>
)}
</div>
</div>
);
}
function StepThree({ data, errors, touched, onChange, onBlur }) {
return (
<div className="step-container">
<h2>Informações de Pagamento</h2>
<div className="form-group">
<label htmlFor="cardNumber">Número do Cartão *</label>
<input
id="cardNumber"
type="text"
name="cardNumber"
value={data.cardNumber}
onChange={onChange}
onBlur={() => onBlur('cardNumber')}
className={errors.cardNumber && touched.cardNumber ? 'input-error' : ''}
placeholder="XXXX XXXX XXXX XXXX"
/>
{errors.cardNumber && touched.cardNumber && (
<span className="error-message">{errors.cardNumber}</span>
)}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="expiryDate">Data de Expiração *</label>
<input
id="expiryDate"
type="text"
name="expiryDate"
value={data.expiryDate}
onChange={onChange}
onBlur={() => onBlur('expiryDate')}
className={errors.expiryDate && touched.expiryDate ? 'input-error' : ''}
placeholder="MM/AA"
/>
{errors.expiryDate && touched.expiryDate && (
<span className="error-message">{errors.expiryDate}</span>
)}
</div>
<div className="form-group">
<label htmlFor="cvv">CVV *</label>
<input
id="cvv"
type="text"
name="cvv"
value={data.cvv}
onChange={onChange}
onBlur={() => onBlur('cvv')}
className={errors.cvv && touched.cvv ? 'input-error' : ''}
placeholder="XXX"
/>
{errors.cvv && touched.cvv && (
<span className="error-message">{errors.cvv}</span>
)}
</div>
</div>
</div>
);
}
Cada componente de etapa recebe props para dados, erros, campos tocados e callbacks. Condicionalmente exibe mensagens de erro apenas para campos tocados. Essa estrutura modular permite reutilização e testes independentes.
Aprimoramentos Avançados
Persistência de dados com LocalStorage
Para formulários longos, salve os dados periodicamente no navegador para evitar perda de informações se a página for recarregada.
useEffect(() => {
// Salvar formData no localStorage a cada mudança
localStorage.setItem('multiStepFormData', JSON.stringify(formData));
}, [formData]);
useEffect(() => {
// Recuperar formData ao montar o componente
const savedData = localStorage.getItem('multiStepFormData');
if (savedData) {
try {
setFormData(JSON.parse(savedData));
} catch (error) {
console.error('Erro ao carregar dados salvos:', error);
}
}
}, []);
Este efeito salva automaticamente os dados do formulário sempre que mudam. Ao recarregar a página, os dados são restaurados. Embora simples, evita frustrações de perda de dados.
Desabilitar campos condicionais
Alguns campos podem ser opcionais ou habilitados apenas se outras condições forem atendidas. Implemente lógica condicional nos componentes de etapa.
function StepTwo({ data, errors, touched, onChange, onBlur }) {
const [isInternational, setIsInternational] = useState(false);
return (
<div className="step-container">
<h2>Endereço</h2>
<div className="form-group">
<label>
<input
type="checkbox"
checked={isInternational}
onChange={(e) => setIsInternational(e.target.checked)}
/>
Endereço Internacional
</label>
</div>
{isInternational && (
<div className="form-group">
<label htmlFor="country">País *</label>
<input
id="country"
type="text"
name="country"
// ... props ...
/>
</div>
)}
{/* ... restante dos campos ... */}
</div>
);
}
Campos condicionais melhoram a experiência ocultando campos irrelevantes até que se tornem necessários.
Usando Context API para formulários globais
Em aplicações maiores, considere usar Context API para evitar prop drilling excessivo.
const FormContext = createContext();
function FormProvider({ children }) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
city: '',
cardNumber: '',
expiryDate: '',
cvv: ''
});
const [currentStep, setCurrentStep] = useState(1);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const value = {
formData, setFormData,
currentStep, setCurrentStep,
errors, setErrors,
touched, setTouched
};
return (
<FormContext.Provider value={value}>
{children}
</FormContext.Provider>
);
}
export function useForm() {
const context = useContext(FormContext);
if (!context) {
throw new Error('useForm deve ser usado dentro de FormProvider');
}
return context;
Components filhos podem acessar o contexto sem receber props manualmente:
function StepOne() {
const { formData, errors, touched, setFormData, setTouched } = useForm();
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleFieldTouched = (fieldName) => {
setTouched(prev => ({ ...prev, [fieldName]: true }));
};
// ... restante do código ...
}
Context API reduz complexidade em formulários de múltiplas etapas distribuídos em vários componentes.
Conclusão
Dominar formulários multi-step em React exige compreender três pilares: gerenciar estado centralizado que persiste dados entre etapas, validar parcialmente em cada transição enquanto oferece feedback em tempo real apenas para campos tocados, e implementar navegação fluida que respeita o fluxo sem perder informações. A combinação desses elementos cria uma experiência robusta e profissional. Conforme seus projetos crescem, considere abstrair a lógica em hooks customizados ou usar bibliotecas como Formik ou React Hook Form para reduzir boilerplate e aumentar manutenibilidade.