<h2>Entendendo Formulários Multi-step em React</h2>
<p>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.</p>
<p>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.</p>
<h3>Por que não é trivial?</h3>
<p>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.</p>
<h2>Gerenciamento de Estado</h2>
<h3>Estruturando o estado do formulário</h3>
<p>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.</p>
<pre><code class="language-jsx">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({});</code></pre>
<p>Esta estrutura simples permite que você mantenha todos os dados simultaneamente, evitando perda de informações quando o usuário navega. O objeto <code>touched</code> rastreia quais campos o usuário já interagiu, útil para mostrar erros apenas após o usuário sair do campo.</p>
<h3>Atualizando valores de campos</h3>
<p>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.</p>
<pre><code class="language-jsx">const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prevState => ({
...prevState,
[name]: value
}));
};
const handleFieldTouched = (fieldName) => {
setTouched(prevState => ({
...prevState,
[fieldName]: true
}));
};</code></pre>
<p>Quando o usuário digita em um campo, <code>handleInputChange</code> atualiza seu valor. Quando sai do campo (evento <code>onBlur</code>), <code>handleFieldTouched</code> marca-o como tocado. Essa separação permite que você mostre mensagens de erro apenas para campos já visitados, melhorando a experiência.</p>
<h2>Validação em Formulários Multi-step</h2>
<h3>Estratégia de validação por etapa</h3>
<p>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.</p>
<pre><code class="language-jsx">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;
};</code></pre>
<p>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 <code>null</code>. A função <code>validateStep</code> aplica todas as regras de uma etapa específica e retorna um booleano indicando sucesso.</p>
<h3>Validação em tempo real</h3>
<p>Para melhorar a experiência, valide campos individuais enquanto o usuário digita, mas apenas se o campo já foi tocado.</p>
<pre><code class="language-jsx">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
}));
}
};</code></pre>
<p>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.</p>
<h2>Navegação e Fluxo de Etapas</h2>
<h3>Funções de navegação</h3>
<p>Implemente funções que gerenciam a transição entre etapas, validando a etapa atual antes de avançar e permitindo voltar sem validação.</p>
<pre><code class="language-jsx">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.
}
};</code></pre>
<p>A função <code>handleNext</code> valida a etapa atual usando <code>validateStep</code>. Se há erros, a etapa não muda. A função <code>handlePrevious</code> permite voltar sem validação, preservando dados. O <code>window.scrollTo(0, 0)</code> melhora a experiência rolando para o topo quando a etapa muda.</p>
<h3>Componente de renderização condicional</h3>
<p>Estruture o componente principal para renderizar diferentes componentes de etapa conforme <code>currentStep</code> muda.</p>
<pre><code class="language-jsx">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>
);
}</code></pre>
<p>Este padrão é claro e escalável. Um <code>switch</code> 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.</p>
<h3>Componentes de etapa individuais</h3>
<p>Cada etapa é um componente reutilizável que exibe campos específicos. Mantê-los separados melhora legibilidade e testabilidade.</p>
<pre><code class="language-jsx">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>
);
}</code></pre>
<p>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.</p>
<h2>Aprimoramentos Avançados</h2>
<h3>Persistência de dados com LocalStorage</h3>
<p>Para formulários longos, salve os dados periodicamente no navegador para evitar perda de informações se a página for recarregada.</p>
<pre><code class="language-jsx">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);
}
}
}, []);</code></pre>
<p>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.</p>
<h3>Desabilitar campos condicionais</h3>
<p>Alguns campos podem ser opcionais ou habilitados apenas se outras condições forem atendidas. Implemente lógica condicional nos componentes de etapa.</p>
<pre><code class="language-jsx">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>
);
}</code></pre>
<p>Campos condicionais melhoram a experiência ocultando campos irrelevantes até que se tornem necessários.</p>
<h3>Usando Context API para formulários globais</h3>
<p>Em aplicações maiores, considere usar Context API para evitar prop drilling excessivo.</p>
<pre><code class="language-jsx">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;</code></pre>
<p>Components filhos podem acessar o contexto sem receber props manualmente:</p>
<pre><code class="language-jsx">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 ...
}</code></pre>
<p>Context API reduz complexidade em formulários de múltiplas etapas distribuídos em vários componentes.</p>
<h2>Conclusão</h2>
<p>Dominar formulários multi-step em React exige compreender três pilares: <strong>gerenciar estado centralizado</strong> que persiste dados entre etapas, <strong>validar parcialmente</strong> em cada transição enquanto oferece feedback em tempo real apenas para campos tocados, e <strong>implementar navegação fluida</strong> 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.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://react.dev/reference/react-dom/components/input" target="_blank" rel="noopener noreferrer">React Official Documentation - Forms</a></li>
<li><a href="https://react-hook-form.com/" target="_blank" rel="noopener noreferrer">React Hook Form - Building Forms Better</a></li>
<li><a href="https://formik.org/" target="_blank" rel="noopener noreferrer">Formik - Build forms in React, without the tears</a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation" target="_blank" rel="noopener noreferrer">MDN Web Docs - Client-side form validation</a></li>
<li><a href="https://css-tricks.com/multi-step-form-using-react-and-styled-components/" target="_blank" rel="noopener noreferrer">CSS-Tricks - Multi-Step Form Tutorial</a></li>
</ul>
<p><!-- FIM --></p>