Formulários em React Native: validação, máscaras e experiência de uso

Capítulo 9

Tempo estimado de leitura: 13 minutos

+ Exercício

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) e autoComplete (Android/iOS): ajudam autofill (ex.: emailAddress, password).
  • autoCapitalize: geralmente none para e-mail/usuário, words para nome.
  • autoCorrect: normalmente false para e-mail, usuário e códigos.
  • returnKeyType: next, done, send para orientar o fluxo.
  • secureTextEntry: senha.
  • inputMode: alternativa moderna para sugerir tipo de entrada (nem sempre substitui keyboardType).

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Acessibilidade em formulários

Formulários precisam ser navegáveis e compreensíveis por leitores de tela. Boas práticas:

  • Use accessibilityLabel quando 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 controlar isValid.
  • Erros aparecem próximos ao campo, com borda destacada e mensagem textual.
  • Botão desabilitado quando inválido ou enviando.
  • isSubmitting local 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: onBlur reduz ruído; onChange dá feedback imediato. Uma combinação comum é: validar formato no onChange e regras mais “chatas” no onBlur.
  • Evite mostrar erro antes do usuário interagir (use touchedFields do RHF quando fizer sentido).

Boas práticas rápidas (checklist)

ObjetivoO que usarExemplo
Teclado adequadokeyboardTypeemail-address, phone-pad, numeric
Evitar capitalização indevidaautoCapitalizenone para e-mail
Fluxo entre camposreturnKeyType + onSubmitEditing + refsnext para avançar, done para finalizar
Não esconder campos/botãoKeyboardAvoidingView + ScrollViewbehavior="padding" no iOS
Validação robustaSchema (Zod/Yup) + resolverzodResolver(schema)
Evitar múltiplos enviosdisabled + guarda no handlerif (isSubmitting) return
Máscaras sem perder dadosArmazenar 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.

Agora responda o exercício sobre o conteúdo:

Ao implementar máscaras de telefone e moeda em um formulário React Native, qual abordagem tende a reduzir erros e manter o estado do formulário consistente?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Guardar o valor “limpo” (só dígitos/centavos) evita inconsistências e facilita validação e envio. A formatação fica apenas na UI, mostrando ao usuário o formato esperado sem perder dados.

Próximo capitúlo

Consumo de APIs em React Native: HTTP, autenticação e tratamento robusto de erros

Arrow Right Icon
Capa do Ebook gratuito React Native Essencial: criando apps completos com JavaScript e boas práticas
56%

React Native Essencial: criando apps completos com JavaScript e boas práticas

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.