Pular para o conteúdo

0005 — Forms Subsystem (subsistema genérico de formulários)

Status

Accepted · 2026-04-26 · Autor: Alexandre + Claude.

Contexto

Plataforma tem dezenas de formulários: signup, login, criação de lead, questionário de onboarding, criação de proposta, edição de perfil, configuração de consultoria, solicitação Acolhe, etc.

Sem subsistema unificado, cada dev cria seu padrão. Resultado: 20 formulários com 20 jeitos de validar, mostrar erro, traduzir. Manutenção pesadelo.

Decisão

1. Stack canônico

  • react-hook-form (estado do form)
  • Zod (validação tipada, schemas reutilizáveis)
  • @clinicgestor/schemas (compartilhado entre front e edge functions)
  • useFieldError (hook que resolve chave Zod → tradução PT-BR via useT)

2. Schemas Zod com chaves i18n

packages/schemas/src/auth/sign-in.ts
import { z } from 'zod';
import { emailField, passwordField, captchaTokenField } from '../common';
export const signInSchema = z.object({
email: emailField,
password: passwordField,
captchaToken: captchaTokenField,
});
export type SignInInput = z.infer<typeof signInSchema>;

Em common.ts:

export const emailField = z
.string({ required_error: 'validation:email.required' })
.min(1, 'validation:email.required')
.email('validation:email.invalid');
export const passwordField = z
.string({ required_error: 'validation:password.required' })
.min(12, 'validation:password.too_short')
.regex(/[a-z]/, 'validation:password.missing_lowercase')
.regex(/[A-Z]/, 'validation:password.missing_uppercase')
.regex(/[0-9]/, 'validation:password.missing_digit')
.regex(/[^a-zA-Z0-9]/, 'validation:password.missing_symbol');
export const captchaTokenField = z
.string({ required_error: 'validation:captcha.required' })
.min(1, 'validation:captcha.required');

Mensagens são chaves i18n (namespace:key.subkey), nunca strings cruas.

3. useFieldError — resolve chave → tradução

packages/i18n/src/use-field-error.ts
export function useFieldError(error?: { message?: string }) {
const { t } = useT(); // useT do @clinicgestor/i18n
if (!error?.message) return undefined;
// Se a mensagem é chave i18n (`ns:key`), traduz
if (error.message.includes(':')) {
return t(error.message);
}
// Senão é string crua (legado), retorna direto
return error.message;
}

Uso em componente:

const {
register,
formState: { errors },
} = useForm<SignInInput>({
resolver: zodResolver(signInSchema),
});
const emailError = useFieldError(errors.email);
return (
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" {...register('email')} aria-invalid={!!emailError} />
{emailError && <p className="text-danger text-sm">{emailError}</p>}
</div>
);

4. mapAuthErrorKey — erros de auth API

Erros do Supabase Auth (e outras APIs) retornam mensagens em inglês não amigáveis. Mapeamos pra chaves i18n:

packages/i18n/src/map-auth-error.ts
export function mapAuthErrorKey(error: AuthError): string {
const code = error.code ?? error.message;
switch (code) {
case 'invalid_credentials':
return 'auth:errors.invalid_credentials';
case 'email_not_confirmed':
return 'auth:errors.email_not_confirmed';
case 'over_email_send_rate_limit':
return 'auth:errors.rate_limit';
// ...
default:
return 'auth:errors.unknown';
}
}

Uso:

try {
await supabase.auth.signInWithPassword({ email, password });
} catch (err) {
const errorKey = mapAuthErrorKey(err);
toast.error(t(errorKey));
}

5. Engine de Forms genéricos (questionários, onboarding)

Pra forms dinâmicos (questionários do onboarding de lead, formulários de Programa Acolhe, etc), engine genérica em packages/forms (futuro):

// Spec de form em JSON
{
"id": "lead_onboarding_v1",
"fields": [
{ "key": "clinic_size", "type": "select", "options": ["small", "medium", "large"], "required": true },
{ "key": "specialty", "type": "text", "required": false },
{ "key": "monthly_revenue_brl", "type": "number_brl", "required": true, "validation": { "min": 0 } },
]
}

Engine renderiza, valida, submete. Permite Alexandre criar questionários sem dev.

(Em F1 podemos começar com schemas Zod hard-coded por questionário; engine genérica é F1.5+.)

6. Padrões obrigatórios

  • <form> com onSubmit={handleSubmit(onSubmit)} (RHF + Zod resolver)
  • Label sempre antes do input (acessibilidade)
  • aria-invalid quando erro
  • Botão “Salvar” disabled durante submit (isSubmitting)
  • Confirmação visual (toast) após sucesso
  • Schemas Zod reutilizáveis entre front e edge functions

Consequências

Positivas

  • Consistência total entre formulários
  • i18n correta sem hardcode
  • Type-safe entre validação e API
  • Reuso entre front e edge functions (mesmo schema)
  • Acessibilidade built-in

Negativas

  • Curva de aprendizado pra dev novo (precisa entender RHF + Zod + useFieldError)
  • Schemas centralizados em @clinicgestor/schemas — qualquer dev que mexe afeta múltiplos consumers

Riscos

  • Mudança em schema centralizado quebra múltiplos formulários
    • Mitigação: tests de schemas em packages/schemas/src/<area>/schemas.test.ts
  • Engine genérica de forms (F1.5+) pode virar over-engineering
    • Mitigação: começar com schemas Zod hard-coded; só adotar engine quando 5+ questionários parecidos justificarem

Alternativas consideradas

1. Formik

  • Prós: maduro
  • Contras: maior bundle; integração com Zod menos direta
  • Rejeitado por preferência de RHF (mais leve, mais idiomático React).

2. Validação custom sem Zod

  • Prós: zero dependência
  • Contras: sem type inference; sem reuso back/front; sem composição
  • Rejeitado.

3. Schemas duplicados (front e back)

  • Prós: independência
  • Contras: drift garantido; bug em produção esperando
  • Rejeitado — schemas compartilhados é princípio.

Atualizações de documentos

  • AGENTS.md §5.10 (i18n) e §5.11 (schemas)
  • docs/forms-subsystem.md (detalhes técnicos)

Referências

  • AGENTS.md §5.10, §5.11
  • Documentação react-hook-form e Zod