O que torna formulários “difíceis” no mobile
Em React Native, formulários exigem atenção a detalhes que impactam diretamente a taxa de conversão e a percepção de qualidade: controle de foco entre campos, comportamento do teclado, validação com feedback imediato, prevenção de múltiplos envios, máscaras/formatadores e acessibilidade. A ideia é reduzir atrito: o usuário deve entender o que preencher, receber erros claros e conseguir enviar sem “brigar” com o teclado.
TextInput na prática: configurações que melhoram UX
Props essenciais para experiência de uso
Algumas props do TextInput ajudam a guiar o teclado e o comportamento do texto:
keyboardType: escolhe o teclado (ex.:email-address,numeric,phone-pad).textContentType(iOS) eautoComplete(Android/iOS): ajudam autofill (ex.:emailAddress,password).autoCapitalize: geralmentenonepara e-mail/usuário,wordspara nome.autoCorrect: normalmentefalsepara e-mail, usuário e códigos.returnKeyType:next,done,sendpara orientar o fluxo.secureTextEntry: senha.inputMode: alternativa moderna para sugerir tipo de entrada (nem sempre substituikeyboardType).
Controle de foco (Next/Done) com refs
Em formulários com múltiplos campos, é comum usar onSubmitEditing para mover o foco para o próximo input. Isso evita o usuário tocar manualmente em cada campo.
import React, { useRef } from "react";import { TextInput, View } from "react-native";export function FocusExample() { const emailRef = useRef<TextInput>(null); const passwordRef = useRef<TextInput>(null); return ( <View> <TextInput placeholder="Nome" returnKeyType="next" autoCapitalize="words" onSubmitEditing={() => emailRef.current?.focus()} /> <TextInput ref={emailRef} placeholder="E-mail" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} returnKeyType="next" onSubmitEditing={() => passwordRef.current?.focus()} /> <TextInput ref={passwordRef} placeholder="Senha" secureTextEntry returnKeyType="done" /> </View> );}Teclado: evitar que o botão de submit fique escondido
Quando o teclado abre, é comum ele cobrir o botão de envio. Combine KeyboardAvoidingView e ScrollView para permitir rolagem e manter campos acessíveis.
import React from "react";import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";export function FormLayout({ children }: { children: React.ReactNode }) { return ( <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} > <ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled" > <View style={{ gap: 12 }}>{children}</View> </ScrollView> </KeyboardAvoidingView> );}keyboardShouldPersistTaps="handled" ajuda a permitir cliques em botões/inputs mesmo com o teclado aberto.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Acessibilidade em formulários
Formulários precisam ser navegáveis e compreensíveis por leitores de tela. Boas práticas:
- Use
accessibilityLabelquando o placeholder não for suficiente (placeholders podem sumir ao digitar). - Associe mensagens de erro ao campo (visual e semanticamente).
- Garanta contraste e tamanho de toque adequado em botões.
- Evite depender apenas de cor para indicar erro; inclua texto e ícone se necessário.
<TextInput accessibilityLabel="E-mail" placeholder="E-mail" keyboardType="email-address" autoCapitalize="none" autoCorrect={false}/>Validação: síncrona vs assíncrona
Validação síncrona (imediata)
É a validação que você consegue fazer localmente: formato de e-mail, mínimo de caracteres, campo obrigatório, confirmação de senha. Ela deve ser rápida e previsível.
Validação assíncrona (depende de servidor)
Exemplos: verificar se e-mail já existe, se cupom é válido, se usuário está disponível. Aqui é importante:
- Debounce para não disparar requisições a cada tecla.
- Cancelar/ignorar respostas antigas (race conditions).
- Mostrar estado “verificando...” sem travar o formulário.
React Hook Form: formulários performáticos e escaláveis
O React Hook Form (RHF) reduz re-renders e centraliza regras de validação/estado. Em React Native, você normalmente usa Controller para integrar com TextInput.
Passo a passo: formulário de login com RHF + schema (Zod)
Exemplo com validação por schema usando Zod. A mesma ideia funciona com Yup (via resolver correspondente).
1) Defina o schema e o tipo
import { z } from "zod";export const loginSchema = z.object({ email: z.string().email("Informe um e-mail válido"), password: z.string().min(6, "Mínimo de 6 caracteres"),});export type LoginFormData = z.infer<typeof loginSchema>;2) Monte o formulário com Controller
import React, { useRef, useState } from "react";import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native";import { Controller, useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { loginSchema, type LoginFormData } from "./loginSchema";export function LoginForm() { const passwordRef = useRef<TextInput>(null); const [isSubmitting, setIsSubmitting] = useState(false); const { control, handleSubmit, formState: { errors, isValid }, } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema), mode: "onChange", }); async function onSubmit(data: LoginFormData) { if (isSubmitting) return; setIsSubmitting(true); try { await new Promise((r) => setTimeout(r, 800)); } finally { setIsSubmitting(false); } } return ( <View style={{ gap: 12 }}> <Controller control={control} name="email" render={({ field: { onChange, onBlur, value } }) => ( <View style={{ gap: 6 }}> <TextInput placeholder="E-mail" accessibilityLabel="E-mail" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} returnKeyType="next" onBlur={onBlur} value={value} onChangeText={onChange} onSubmitEditing={() => passwordRef.current?.focus()} style={{ borderWidth: 1, borderColor: errors.email ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.email && ( <Text style={{ color: "#D00" }}>{errors.email.message}</Text> )} </View> )} /> <Controller control={control} name="password" render={({ field: { onChange, onBlur, value } }) => ( <View style={{ gap: 6 }}> <TextInput ref={passwordRef} placeholder="Senha" accessibilityLabel="Senha" secureTextEntry returnKeyType="done" onBlur={onBlur} value={value} onChangeText={onChange} style={{ borderWidth: 1, borderColor: errors.password ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.password && ( <Text style={{ color: "#D00" }}>{errors.password.message}</Text> )} </View> )} /> <Pressable onPress={handleSubmit(onSubmit)} disabled={!isValid || isSubmitting} accessibilityRole="button" style={{ backgroundColor: !isValid || isSubmitting ? "#999" : "#111", padding: 14, borderRadius: 8, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 10, }} > {isSubmitting && <ActivityIndicator color="#FFF" />} <Text style={{ color: "#FFF", fontWeight: "600" }}>Entrar</Text> </Pressable> </View> );}Pontos importantes do exemplo:
mode: "onChange"permite validar enquanto digita e controlarisValid.- Erros aparecem próximos ao campo, com borda destacada e mensagem textual.
- Botão desabilitado quando inválido ou enviando.
isSubmittinglocal previne múltiplos envios (toques repetidos) e controla loading.
Prevenção de múltiplos envios: além do disabled
Desabilitar o botão ajuda, mas ainda é bom proteger no handler para evitar condições de corrida (por exemplo, se o estado demorar a atualizar).
async function onSubmit(data: LoginFormData) { if (isSubmitting) return; setIsSubmitting(true); try { await api.login(data); } finally { setIsSubmitting(false); }}Validação assíncrona com RHF: checar disponibilidade de e-mail
Uma abordagem comum é validar no onBlur (quando o usuário sai do campo), reduzindo requisições. Você pode usar setError e clearErrors.
import React, { useRef, useState } from "react";import { Text, TextInput, View } from "react-native";import { Controller, useForm } from "react-hook-form";import { z } from "zod";import { zodResolver } from "@hookform/resolvers/zod";const schema = z.object({ email: z.string().email("Informe um e-mail válido"),});type Data = z.infer<typeof schema>;async function checkEmailAvailable(email: string) { await new Promise((r) => setTimeout(r, 500)); return email.toLowerCase() !== "teste@exemplo.com";}export function EmailAvailabilityField() { const [checking, setChecking] = useState(false); const { control, setError, clearErrors, getValues, formState: { errors } } = useForm<Data>({ resolver: zodResolver(schema), mode: "onChange", }); async function validateEmailOnBlur() { const email = getValues("email"); if (!email) return; if (errors.email) return; setChecking(true); try { const ok = await checkEmailAvailable(email); if (!ok) { setError("email", { type: "validate", message: "Este e-mail já está em uso" }); } else { clearErrors("email"); } } finally { setChecking(false); } } return ( <Controller control={control} name="email" render={({ field: { onChange, onBlur, value } }) => ( <View style={{ gap: 6 }}> <TextInput placeholder="E-mail" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} value={value} onChangeText={onChange} onBlur={() => { onBlur(); void validateEmailOnBlur(); }} style={{ borderWidth: 1, borderColor: errors.email ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {checking && <Text style={{ color: "#555" }}>Verificando...</Text>} {!!errors.email && <Text style={{ color: "#D00" }}>{errors.email.message}</Text>} </View> )} /> );}Dica: se você optar por validar enquanto digita, implemente debounce e ignore respostas antigas (por exemplo, guardando um contador/ID da última requisição).
Máscaras e formatadores: telefone e moeda
Em mobile, máscaras ajudam o usuário a digitar no formato esperado. Em React Native, você pode:
- Aplicar máscara manualmente (funções puras que formatam o texto).
- Usar bibliotecas de máscara (quando o projeto pede muitos formatos e consistência).
Máscara manual de telefone (Brasil) integrada ao RHF
Exemplo simples que mantém apenas dígitos e aplica formatação. A máscara deve ser tolerante: não “brigar” com o usuário enquanto ele digita.
function onlyDigits(value: string) { return value.replace(/\D/g, "");}function formatBRPhone(value: string) { const d = onlyDigits(value).slice(0, 11); if (d.length <= 2) return d; if (d.length <= 6) return `(${d.slice(0, 2)}) ${d.slice(2)}`; if (d.length <= 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`; return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;}<Controller control={control} name="phone" render={({ field: { value, onChange, onBlur } }) => ( <TextInput placeholder="Telefone" keyboardType="phone-pad" returnKeyType="next" value={formatBRPhone(value ?? "")} onChangeText={(text) => onChange(onlyDigits(text))} onBlur={onBlur} style={{ borderWidth: 1, borderColor: "#CCC", padding: 12, borderRadius: 8 }} /> )}/>Note que o valor armazenado no form fica “limpo” (apenas dígitos), enquanto o usuário vê o valor formatado.
Moeda: armazenar centavos e exibir formatado
Para evitar problemas com ponto flutuante, uma prática comum é armazenar o valor em centavos (inteiro) e formatar para exibição.
function formatBRLFromCents(cents: number) { const value = (cents / 100).toFixed(2); const [intPart, decPart] = value.split("."); const withThousands = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "."); return `R$ ${withThousands},${decPart}`;}function parseCentsFromText(text: string) { const digits = text.replace(/\D/g, ""); const asNumber = Number(digits || "0"); return asNumber;}<Controller control={control} name="amountCents" render={({ field: { value, onChange } }) => ( <TextInput placeholder="Valor" keyboardType="numeric" value={formatBRLFromCents(Number(value ?? 0))} onChangeText={(text) => onChange(parseCentsFromText(text))} style={{ borderWidth: 1, borderColor: "#CCC", padding: 12, borderRadius: 8 }} /> )}/>Assim, o usuário digita “1234” e vê “R$ 12,34”, enquanto o estado guarda 1234 centavos.
Mensagens de erro: clareza, momento e consistência
Boas mensagens de erro são específicas e acionáveis. Recomendações:
- Prefira “Informe um e-mail válido” em vez de “E-mail inválido”.
- Mostre erro próximo ao campo e, se necessário, um resumo no topo (em formulários longos).
- Escolha quando validar:
onBlurreduz ruído;onChangedá feedback imediato. Uma combinação comum é: validar formato noonChangee regras mais “chatas” noonBlur. - Evite mostrar erro antes do usuário interagir (use
touchedFieldsdo RHF quando fizer sentido).
Boas práticas rápidas (checklist)
| Objetivo | O que usar | Exemplo |
|---|---|---|
| Teclado adequado | keyboardType | email-address, phone-pad, numeric |
| Evitar capitalização indevida | autoCapitalize | none para e-mail |
| Fluxo entre campos | returnKeyType + onSubmitEditing + refs | next para avançar, done para finalizar |
| Não esconder campos/botão | KeyboardAvoidingView + ScrollView | behavior="padding" no iOS |
| Validação robusta | Schema (Zod/Yup) + resolver | zodResolver(schema) |
| Evitar múltiplos envios | disabled + guarda no handler | if (isSubmitting) return |
| Máscaras sem perder dados | Armazenar valor “limpo” | Telefone só dígitos; moeda em centavos |
Exercício guiado: cadastro com nome, e-mail, telefone e valor
Objetivo
Criar um formulário com:
- Nome (capitalização por palavras)
- E-mail (teclado e validação)
- Telefone (máscara)
- Valor (moeda em centavos)
- Submit com loading e bloqueio de múltiplos envios
Passo a passo
1) Schema
import { z } from "zod";export const signupSchema = z.object({ name: z.string().min(2, "Informe seu nome"), email: z.string().email("Informe um e-mail válido"), phone: z.string().min(10, "Informe um telefone válido"), amountCents: z.number().min(100, "Valor mínimo: R$ 1,00"),});export type SignupData = z.infer<typeof signupSchema>;2) Formulário
import React, { useRef, useState } from "react";import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native";import { Controller, useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { signupSchema, type SignupData } from "./signupSchema";function onlyDigits(value: string) { return value.replace(/\D/g, "");}function formatBRPhone(value: string) { const d = onlyDigits(value).slice(0, 11); if (d.length <= 2) return d; if (d.length <= 6) return `(${d.slice(0, 2)}) ${d.slice(2)}`; if (d.length <= 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`; return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;}function formatBRLFromCents(cents: number) { const value = (cents / 100).toFixed(2); const [intPart, decPart] = value.split("."); const withThousands = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "."); return `R$ ${withThousands},${decPart}`;}function parseCentsFromText(text: string) { const digits = text.replace(/\D/g, ""); return Number(digits || "0");}export function SignupForm() { const emailRef = useRef<TextInput>(null); const phoneRef = useRef<TextInput>(null); const amountRef = useRef<TextInput>(null); const [isSubmitting, setIsSubmitting] = useState(false); const { control, handleSubmit, formState: { errors, isValid } } = useForm<SignupData>({ resolver: zodResolver(signupSchema), mode: "onChange", defaultValues: { amountCents: 0 }, }); async function onSubmit(data: SignupData) { if (isSubmitting) return; setIsSubmitting(true); try { await new Promise((r) => setTimeout(r, 900)); } finally { setIsSubmitting(false); } } return ( <View style={{ gap: 12 }}> <Controller control={control} name="name" render={({ field: { value, onChange, onBlur } }) => ( <View style={{ gap: 6 }}> <TextInput placeholder="Nome" accessibilityLabel="Nome" autoCapitalize="words" returnKeyType="next" value={value} onChangeText={onChange} onBlur={onBlur} onSubmitEditing={() => emailRef.current?.focus()} style={{ borderWidth: 1, borderColor: errors.name ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.name && <Text style={{ color: "#D00" }}>{errors.name.message}</Text>} </View> )} /> <Controller control={control} name="email" render={({ field: { value, onChange, onBlur } }) => ( <View style={{ gap: 6 }}> <TextInput ref={emailRef} placeholder="E-mail" accessibilityLabel="E-mail" keyboardType="email-address" autoCapitalize="none" autoCorrect={false} returnKeyType="next" value={value} onChangeText={onChange} onBlur={onBlur} onSubmitEditing={() => phoneRef.current?.focus()} style={{ borderWidth: 1, borderColor: errors.email ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.email && <Text style={{ color: "#D00" }}>{errors.email.message}</Text>} </View> )} /> <Controller control={control} name="phone" render={({ field: { value, onChange, onBlur } }) => ( <View style={{ gap: 6 }}> <TextInput ref={phoneRef} placeholder="Telefone" accessibilityLabel="Telefone" keyboardType="phone-pad" returnKeyType="next" value={formatBRPhone(value ?? "")} onChangeText={(text) => onChange(onlyDigits(text))} onBlur={onBlur} onSubmitEditing={() => amountRef.current?.focus()} style={{ borderWidth: 1, borderColor: errors.phone ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.phone && <Text style={{ color: "#D00" }}>{errors.phone.message}</Text>} </View> )} /> <Controller control={control} name="amountCents" render={({ field: { value, onChange, onBlur } }) => ( <View style={{ gap: 6 }}> <TextInput ref={amountRef} placeholder="Valor" accessibilityLabel="Valor" keyboardType="numeric" returnKeyType="done" value={formatBRLFromCents(Number(value ?? 0))} onChangeText={(text) => onChange(parseCentsFromText(text))} onBlur={onBlur} style={{ borderWidth: 1, borderColor: errors.amountCents ? "#D00" : "#CCC", padding: 12, borderRadius: 8 }} /> {!!errors.amountCents && <Text style={{ color: "#D00" }}>{errors.amountCents.message}</Text>} </View> )} /> <Pressable onPress={handleSubmit(onSubmit)} disabled={!isValid || isSubmitting} accessibilityRole="button" style={{ backgroundColor: !isValid || isSubmitting ? "#999" : "#111", padding: 14, borderRadius: 8, alignItems: "center", flexDirection: "row", justifyContent: "center", gap: 10, }} > {isSubmitting && <ActivityIndicator color="#FFF" />} <Text style={{ color: "#FFF", fontWeight: "600" }}>Criar conta</Text> </Pressable> </View> );}Ao testar, observe: o teclado muda conforme o campo, o foco avança com “Next”, o telefone e o valor são formatados sem perder o valor “limpo”, erros aparecem no lugar certo e o submit não dispara múltiplas vezes.