Saltar al contenido principal

Formularios

Trabajando con formularios​

Para manejar formularios utilizamos React Hook Form con Zod como validador a través de @hookform/resolvers/zod.

Estructura de un formulario​

Cada formulario se compone de tres piezas distribuidas en las capas de la arquitectura:

ArchivoCapaResponsabilidad
<feature>.form.tsDomainSchema Zod, tipo inferido y factory de default values
<Name>Form.tsxUI / widgetsInicializa useForm, conecta el mutation y envuelve con FormProvider
<Name>FormContent.tsxUI / widgetsRenderiza los inputs y consume el form context
features/user-profile/
domain/
profile.form.ts ← schema + tipo + defaults
ui/
widgets/
ProfileForm.tsx ← useForm + mutation + FormProvider
ProfileFormContent.tsx ← inputs + errores

¿Por qué separar Form y FormContent?

  • El Form maneja la lógica de configuración del formulario (schema, defaults, submit, mutation). No renderiza inputs.
  • El FormContent solo se encarga de renderizar campos y mostrar errores. Consume el contexto de React Hook Form con useFormContext.
  • Esto permite reutilizar el FormContent en distintos contextos (modales, páginas, steppers) sin duplicar la lógica del form.

Definición del formulario​

Declarar el schema Zod, el tipo inferido y la factory de valores por defecto en un archivo .form.ts dentro del domain del feature. Para más detalles sobre esto, consulta la documentación del Domain layer.

// 📁 /apps/features/user-profile/domain/profile.form.ts
import { z } from "zod";
import { TUser } from "@/shared/domain/user/model";

// Schema Zod
export const profileFormDefinition = z.object({
username: z.string().min(1, 'El username es obligatorio'),
fullName: z.string().min(1, 'El nombre es obligatorio'),
});

// Type inferido del schema
export type TProfileForm = z.infer<typeof profileFormDefinition>;

// Default values factory
export function createProfileDefaultValues(data: TUser): TProfileForm {
return {
username: data.username ?? '',
fullName: `${data.firstName} ${data.lastName}` ?? '',
};
}

Contexto del formulario​

✅ Se usa isPending del mutation como estado de loading (no se maneja un useState manual para disabled).

✅ Se usa el Contexto de React Hook Form (FormProvider).

✅ Los default values se crean con la factory del .form.ts.

✅ Solo se maneja lógica de UI en este componente (loading state, disabled state, llamados a handlers, renderizado de componentes, etc).

✅ Form y FormContent son componentes separados.

// 📁 /apps/features/userProfile/ui/widgets/ProfileForm.tsx
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod';
import {
profileFormDefinition,
createProfileDefaultValues,
type TProfileForm,
} from "../../domain/profile.form";

interface ProfileFormProps {
defaultValues: TUser;
}

function ProfileForm({ defaultValues }: ProfileFormProps) {
const updateProfile = useUpdateProfile();

const methods = useForm<TProfileForm>({
defaultValues: createProfileDefaultValues(defaultValues),
resolver: zodResolver(profileFormDefinition),
});

async function onSubmit(data: TProfileForm) {
if (updateProfile.isPending) return;
await updateProfile.mutateAsync(data);
}

return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<ProfileFormContent
disabled={updateProfile.isPending}
error={updateProfile.error}
/>
</form>
</FormProvider>
);
}
export default ProfileForm;

Contenido del formulario​

✅ En caso de existir un error, se maneja el caso.

✅ Se consume el Form Context de React Hook Form.

// 📁 /apps/features/user-profile/ui/widgets/ProfileFormContent.tsx
import { useFormContext } from "react-hook-form";
import { type TProfileForm } from "../../domain/profile.form";

interface ProfileFormContentProps {
disabled: boolean;
}

export function ProfileFormContent({ disabled }: ProfileFormContentProps) {
const {
register,
formState: { errors },
} = useFormContext<TProfileForm>();

return (
<div className="flex flex-col gap-3">
<Input
type="text"
name="username"
id="username"
label="Username"
error={errors.username}
registration={register('username')}
disabled={disabled}
/>
<Input
type="text"
name="fullName"
id="fullName"
label="Nombre completo"
error={errors.fullName}
registration={register('fullName')}
disabled={disabled}
/>
<Button type="submit" disabled={disabled}>
Guardar
</Button>
</div>
);
}

Ejemplo 1 de validación dinámica​

Existen casos donde se necesitan hacer validaciones dinámicas, las cuales sean capaces de adaptar su comportamiento en base a uno o más atributos. En pocas palabras, se necesita incluir reglas de negocio específicas en dichas validaciones.

Para ello se hace uso de Zod y su herramienta superRefine como se plantea a continuación.

Para explicar este caso, planteemos un caso hipotético, donde quisiéramos validar si el ABA o SWIFT fue indicado en una transacción (solo para el caso de que las necesite).

Declarar el type del form​

Se declara el type que nos permita trabajar con todas las reglas de negocio establecidas para nuestro dominio "Transacciones".

// 📁 /apps/features/pay-order/domain/transaction.model.ts
export type RoutingNumberType = "aba" | "swift";

export interface RoutingNumber {
type: RoutingNumberType;
code: string;
}

export interface Transaction {
type: "transfer" | "cash";
amount: number;
isNational: boolean;
routingNumber: RoutingNumber;
isOutgoing: boolean;
}

Reglas de negocio del dominio​

Ahora expresamos las reglas de nuestro dominio

// 📁 /apps/features/pay-order/domain/transaction.logic.ts
import {
Transaction,
RoutingNumberType
} from "./transaction.model.ts";

// international transactions required routing number
function isRoutingNumberRequired(transaction: Transaction): boolean {
return !transaction.isNational;
}

// return regex validation for each Routing Number case
function getRoutingNumberRegexValidation(routingNumber: RoutingNumberType) {
switch(routingNumber) {
// 9 digits required
case "aba": // avoid magic string 😘
return /^\d{9}$/;
// 8 to 11 alphanumeric characters
case "swift": // avoid magic string 😘
return /^[A-Za-z0-9]{8,11}$/;
default:
return null; // throw error works as well
}
}

Validación del form​

// 📁 /apps/features/pay-order/domain/payOrder.form.ts
import { z } from "zod";
import {
isRoutingNumberRequired,
getRoutingNumberRegexValidation
} from "./transaction.logic";

// routing number definition
const routingNumberDefinition = z.object({
type: ['aba', 'swift'],
code: z.string()
});

// transaction definition
const transactionDefinition = z.object({
type: z.enum(['transfer', 'cash']),
amount: z.number().positive(),
isNational: z.boolean(),
routingNumber: routingNumberDefinition.optional()
isOutgoing: z.boolean(),
});

export type TransactionZod = z.infer<typeof transactionDefinition>;

/**
* ! Refactor: this is just a sample, improve SRP in your project.
*/
function refineFunction(
value: TransactionZod,
ctx: z.RefinementCtx,
) {
const isNational = value.isNational;
const routingNumber = value.routingNumber;

if(isNational && routingNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Este campo no es requerido',
path: ['routingNumber'],
});
return;
}

if(isNational) {
return;
}

if(!routingNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Este campo es requerido',
path: ['routingNumber'],
});
return;
}

const regexToValidate = getRoutingNumberRegexValidation(routingNumber.type);
if(!regexToValidate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Ha ocurrido un error validando este campo',
path: ['routingNumber.code'],
});
return;
}

// validate the routing number code against the RegExp
if(!regexToValidate.test(routingNumber.code)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'El código del routing number es inválido',
path: ['routingNumber.code'],
});
return;
}
}

// form validations
export const payOrderFormValidation = z.object({
transactions:
z.array(transactionDefinition.superRefine(refineFunction))
.min(1, { message: 'Debe agregar al menos una transacción.' }),
});

Ejemplo 2 de validación dinámica​

Para el caso donde el esquema recibe una configuración inicial, podríamos hacer lo siguiente:

// 📁 /apps/features/pay-order/domain/payOrder.form.ts
const refineJustOutgoingTransactions = ({
value,
ctx,
}: {
value: TransactionZod;
ctx: z.RefinementCtx;
}) => {
const isOutgoing = value.isOutgoing;
if(!isOutgoing) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Solo se aceptan transacciones salientes',
path: ['isOutgoing'],
});
}
};

const refineJustIncomingTransactions = ({
value,
ctx,
}: {
value: TransactionZod;
ctx: z.RefinementCtx;
}) => {
const isOutgoing = value.isOutgoing;
if(isOutgoing) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Solo se aceptan transacciones entrantes',
path: ['isOutgoing'],
});
}
};

export const transactionTypeDefinition = ({
isOutgoing,
}: {
isOutgoing: boolean;
}) => {
let refineFunction: TRefineTransaction = () => {};
if (isOutgoing) {
refineFunction = (value, ctx) =>
refineJustOutgoingTransactions(value, ctx);
}
if (!isOutgoing) {
refineFunction = (value, ctx) =>
refineJustIncomingTransactions(value, ctx);
}

return inputTransactionDefinition.superRefine(refineFunction);
};

// form validations
export const outgoingOrderFormValidation = z.object({
transactions:
z.array(transactionTypeDefinition({ isOutgoing: true }))
.min(1, { message: 'Debe agregar al menos una transacción.' }),
});

export const incomingOrderFormValidation = z.object({
transactions:
z.array(transactionTypeDefinition({ isOutgoing: false }))
.min(1, { message: 'Debe agregar al menos una transacción.' }),
});