Pular para o conteúdo

Formulários

Guia completo para implementação de formulários na plataforma Gaio.



BibliotecaVersãoPropósito
react-hook-form7.xGerenciamento de formulários
zod4.xValidação de schemas
@gaio/components-Hooks e componentes base
@hookform/resolvers-Integração Zod + RHF
┌─────────────────────────────────────────────────────────────┐
│ useForm │
│ (schema, defaultValues, masks, formatters) │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ <Form {...form}> │
│ (FormProvider) │
└─────────────────────────┬───────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌───────────┐ ┌───────────────┐
│ InputField │ │SelectField│ │ useFieldArray │
│ (withController)│ │ │ │ │
└─────────────────┘ └───────────┘ └───────────────┘
// De @gaio/components
import { Form, useForm } from "@gaio/components";
// De react-hook-form (quando necessário)
import { useWatch, useFieldArray } from "react-hook-form";
// Fields do console
import { InputField } from "@/components/fields/input";
import { SelectField } from "@/components/fields/select";

O hook useForm é um wrapper do react-hook-form com integração Zod automática.

Localização: @gaio/components

ParâmetroTipoObrigatórioDescrição
schemaz.ZodObjectSimSchema Zod para validação
defaultValuesDefaultValues<T>NãoValores iniciais do form
masksRecord<string, Mask>NãoMáscaras por campo (CPF, telefone)
formattersRecord<string, (v) => string>NãoFormatadores de valor
fieldReValidateModeRecord<string, 'onChange'>NãoCampos que revalidam onChange
disabledbooleanNãoDesabilita todo o form
contextRecord<string, any>NãoContexto adicional
import { z } from "zod";
import { Form, useForm } from "@gaio/components";
import { InputField } from "@/components/fields/input";
const ProfileSchema = z.object({
name: z.string().min(1, "Campo obrigatório"),
email: z.email("E-mail inválido"),
});
function ProfileForm() {
const form = useForm({
schema: ProfileSchema,
defaultValues: { name: "", email: "" },
});
const onSubmit = (data: z.infer<typeof ProfileSchema>) => {
console.log(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<InputField name="name" label="Nome" control={form.control} />
<InputField name="email" label="E-mail" control={form.control} />
<button type="submit">Salvar</button>
</form>
</Form>
);
}
import { cpfMask, phoneMask } from "@gaio/common/masks";
const form = useForm({
schema: ContactSchema,
defaultValues: { cpf: "", phone: "" },
masks: {
cpf: cpfMask,
phone: phoneMask,
},
formatters: {
cpf: (value) => value.replace(/\D/g, ""),
},
});
const form = useForm({
schema: LoginSchema,
fieldReValidateMode: {
email: "onChange",
},
});
MétodoDescrição
form.controlController para passar aos fields
form.handleSubmit(fn)Wrapper de submit com validação
form.reset(values?)Reseta o form
form.setValue(name, value, opts)Define valor programaticamente
form.getValues(name?)Obtém valores atuais
form.watch(name?)Observa mudanças (cuidado!)
form.trigger(name?)Dispara validação manual
form.setError(name, error)Define erro manualmente
form.clearErrors(name?)Limpa erros
form.formStateEstado: isDirty, isSubmitting, errors

CampoArquivoDescrição
InputFieldinput.tsxInput de texto, email, password
TextareaFieldtextarea.tsxTextarea com auto-resize
NumberFieldnumber.tsxInput numérico
PhoneFieldphone-field.tsxInput de telefone
SelectFieldselect.tsxSelect dropdown
MultiSelectFieldmulti-select-field.tsxMulti-select
ComboboxFieldcombobox.tsxCombobox com busca
CheckboxFieldcheckbox-field.tsxCheckbox simples
RadioGroupFieldradio-group-field.tsxGrupo de radio buttons
SwitchFieldswitch.tsxToggle switch
DateFielddate.tsxDate picker
ListFieldlist.tsxLista dinâmica de items
UploadFieldupload.tsxUpload de arquivos
AutocompleteFieldautocomplete-field.tsxAutocomplete async

Todos os fields aceitam estas props via BaseFieldProps:

type BaseFieldProps = {
name: string; // Nome do campo no schema
control: Control; // form.control
label?: string; // Label acima do campo
labelAddon?: ReactNode; // Conteúdo à direita da label
description?: string; // Descrição abaixo da label
helper?: string; // Texto auxiliar abaixo do campo
tooltip?: string; // Tooltip no ícone de info
placeholder?: string; // Placeholder do input
disabled?: boolean; // Desabilita o campo
required?: boolean; // Marca como obrigatório (auto-detectado do schema)
hideErrorMessage?: boolean; // Esconde mensagem de erro
mask?: Mask; // Override de máscara
formatter?: (v) => string; // Override de formatter
};
┌──────────────────────────────────────────────┐
│ Label * [labelAddon]│
│ Descrição em texto menor │
├──────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────┐ │
│ │ Input/Select/etc │ │
│ └──────────────────────────────────────────┘ │
│ Helper text em cinza │
│ Mensagem de erro │
└──────────────────────────────────────────────┘

Hook para observar valores de campos de forma otimizada.

Localização: react-hook-form

CenárioSolução
Renderização condicional baseada em valoruseWatch
Side effects em useEffectform.watch() com deps
Validação dependente de outro campoSchema Zod com refine
import { useWatch } from "react-hook-form";
const email = useWatch({
control: form.control,
name: "email",
});
const [name, email] = useWatch({
control: form.control,
name: ["name", "email"],
});
function PaymentForm() {
const form = useForm({ schema: PaymentSchema });
const paymentMethod = useWatch({
control: form.control,
name: "payment_method",
});
return (
<Form {...form}>
<SelectField
name="payment_method"
options={methods}
control={form.control}
/>
{paymentMethod === "credit_card" && (
<>
<InputField name="card_number" control={form.control} />
<InputField name="cvv" control={form.control} />
</>
)}
{paymentMethod === "pix" && (
<InputField name="pix_key" control={form.control} />
)}
</Form>
);
}

Hook para gerenciar arrays dinâmicos de campos.

Localização: react-hook-form

PropriedadeTipoDescrição
fieldsArray<{id, ...data}>Items com ID único
append(data)FunctionAdiciona item ao final
prepend(data)FunctionAdiciona item no início
remove(index)FunctionRemove item por índice
insert(index, data)FunctionInsere em posição específica
swap(from, to)FunctionTroca posição de items
move(from, to)FunctionMove item para posição
replace(data[])FunctionSubstitui todo o array
import { useFieldArray } from "react-hook-form";
const InviteSchema = z.object({
invites: z
.array(
z.object({
email: z.email("E-mail inválido"),
}),
)
.max(10, "Máximo 10 convites"),
});
function InviteForm() {
const form = useForm({ schema: InviteSchema });
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
return (
<Form {...form}>
{fields.map((field, index) => (
<div key={field.id}>
<InputField name={`invites.${index}.email`} control={form.control} />
<Button onClick={() => remove(index)}>Remover</Button>
</div>
))}
<Button onClick={() => append({ email: "" })}>Adicionar Convite</Button>
</Form>
);
}

import { z } from "zod";
const ProfileSchema = z.object({
name: z.string().min(1, "Obrigatório").max(100, "Máximo 100 caracteres"),
email: z.email("E-mail inválido"),
age: z.number().min(18, "Deve ser maior de idade").optional(),
});
type ProfileForm = z.infer<typeof ProfileSchema>;
import { isValidPhoneNumber } from "libphonenumber-js";
const ContactSchema = z.object({
whatsapp: z.custom<string>(
(value) => isValidPhoneNumber((value as string) || ""),
{ error: "Número de WhatsApp inválido" },
),
});

Use .safeExtend() para estender schemas existentes:

import { UserSchema } from "@/services/user/types";
export const UserFormSchema = UserSchema.safeExtend({
confirmPassword: z.string(),
terms: z.boolean().refine((v) => v, "Aceite os termos"),
});
const PaymentSchema = z
.object({
method: z.enum(["pix", "card"]),
pixKey: z.string().optional(),
cardNumber: z.string().optional(),
})
.refine(
(data) => {
if (data.method === "pix") return !!data.pixKey;
if (data.method === "card") return !!data.cardNumber;
return true;
},
{ message: "Preencha os dados do pagamento", path: ["method"] },
);

O HOC withController encapsula fields com Controller automaticamente.

// @/components/fields/utils/with-controller.tsx
import { withController } from "@/components/fields/utils/with-controller";
export const MyCustomField = withController<MyFieldProps>(
({ value, onChange, disabled, ...props }) => {
return (
<MyComponent
value={value}
onChange={(newValue) => onChange(newValue)}
disabled={disabled}
{...props}
/>
);
},
);
// Uso
<MyCustomField name="custom" control={form.control} label="Custom" />;

O componente ListField é uma alternativa declarativa ao useFieldArray.

// @/components/fields/list.tsx
import { ListField } from "@/components/fields/list";
<ListField<{ text: string }>
name="messages"
label="Mensagens"
control={form.control}
onItemData={() => ({ text: "" })}
onRenderItem={(item, index, control) => (
<InputField
name={`messages.${index}.text`}
control={control}
placeholder="Digite a mensagem..."
/>
)}
minItems={1}
maxItems={5}
addButtonPosition="bottom"
/>;
// Verificar se form foi modificado
const canSubmit = form.formState.isDirty;
// Loading state
const isSubmitting = form.formState.isSubmitting;
// Reset após submit
const onSubmit = async (data) => {
await saveData(data);
form.reset(data);
};

Ao usar setValue programaticamente, sempre passe shouldDirty:

// ✅ Correto
form.setValue("field", newValue, { shouldDirty: true });
// ❌ Errado - não marca form como modificado
form.setValue("field", newValue);

// ❌ ERRADO - causa re-renders desnecessários
const value = form.watch("field");
// ✅ CORRETO - re-render apenas quando 'field' muda
const value = useWatch({ control: form.control, name: "field" });
// ❌ ERRADO - valores undefined podem causar problemas
const form = useForm({ schema: MySchema });
// ✅ CORRETO
const form = useForm({
schema: MySchema,
defaultValues: { name: "", items: [] },
});
// ❌ ERRADO - validação manual duplicada
const onSubmit = (data) => {
if (!data.email.includes("@")) {
alert("E-mail inválido");
return;
}
};
// ✅ CORRETO - validação no schema
const Schema = z.object({
email: z.email("E-mail inválido"),
});
// ❌ ERRADO - pode causar bugs de state
{fields.map((field, index) => (
<div key={index}>...</div>
))}
// ✅ CORRETO - usar field.id
{fields.map((field, index) => (
<div key={field.id}>...</div>
))}