Flujos de formularios con máquinas de estados
Formulario de 12 pasos. Validación condicional. El botón Atrás tiene que funcionar.
Modelar un flujo público de Investor Intake con XState, once condiciones de validación, contexto de formulario conservado y pruebas en el navegador.
6 min de lecturaPor Abdullah Raheel
Las transiciones formaban parte del producto
Un contador de pasos solo era suficiente al principio. En el paso cinco, el flujo registraba la posición, los datos acumulados, los errores de validación, la carga, los pasos completados y los requisitos condicionales. En el paso diez, un componente había alcanzado unas 800 líneas y la pregunta «¿puede continuar este usuario?» se respondía en seis lugares distintos.
Mostrar doce pantallas era sencillo. Mantener coherentes las transiciones hacia delante, hacia atrás, asíncronas y de envío sin perder los datos anteriores no lo era.
Un estado organizado todavía puede permitir estados imposibles
useState permitía al formulario combinar valores que no debían coexistir: una pantalla posterior después de borrar datos necesarios, una transición hacia atrás durante el envío o un botón válido mientras un campo condicional visible estaba vacío. useReducer podía centralizar las actualizaciones, pero seguiría aceptando desde cualquier paso toda acción implementada por el reductor, salvo que cada rama recreara el grafo de transiciones.
XState v5 con @xstate/react hizo explícito ese grafo. Todavía se puede enviar un evento, pero una transición no definida o rechazada por una condición no mueve la máquina.
Haz explícito el contrato del flujo
La máquina usaba siete eventos: NEXT, PREVIOUS, GO_TO_STEP, UPDATE_FORM_DATA, SET_CREATED_BUYER_ID, SUBMIT y RESET. El contexto contenía currentStep, formData, errors, isLoading, investorId y createdBuyerId.
CONTACT_PREFERENCES: {
on: {
NEXT: {
target: 'TARGET_MARKETS',
guard: 'CAN_PROGRESS_FROM_CONTACT_PREFERENCES'
},
PREVIOUS: 'INTRO',
UPDATE_FORM_DATA: { actions: 'UPDATE_FORM_DATA' }
}
}NEXT estaba protegido por una condición. PREVIOUS seguía disponible fuera del envío para que el usuario corrigiera datos anteriores sin reiniciar el contexto del formulario. UPDATE_FORM_DATA solo fusionaba los campos modificados con el contexto acumulado.
Coloca la validación en el límite de la transición
Once condiciones seguían la convención CAN_PROGRESS_FROM_[STEP_NAME]. Un esquema de Zod exigía al menos un teléfono o un correo electrónico y solo validaba un teléfono estadounidense cuando estaba presente. Los mercados objetivo, tipos de propiedad y estrategias exigían una selección; las pantallas de rangos exigían al menos un mínimo o un máximo.
const canGoNext = state.can({ type: "NEXT" })
CAN_PROGRESS_FROM_CONTACT_CARD: ({ context }) =>
Boolean(context.createdBuyerId)La condición de Contact Card originalmente devolvía true de manera incondicional. Una corrección posterior la vinculó a createdBuyerId para que la máquina no pudiera abandonar ese estado hasta que el servidor hubiera creado correctamente al comprador. El mismo contrato state.can({ type: "NEXT" }) controlaba el estado del botón y la transición.
Evitar que la verificación asíncrona compita con el formulario
El formulario también integraba API generadas para verificar contactos y crear compradores. Las comprobaciones de teléfono y correo se aplazaban brevemente, y un identificador de solicitud que aumentaba de forma monótona descartaba una respuesta obsoleta si ya se había introducido un valor nuevo. Las recomendaciones de mercados objetivo eliminaban duplicados por coordenadas antes de entrar en el contexto de la máquina.
La creación del comprador ocurría en el límite de Contact Card. SET_CREATED_BUYER_ID guardaba en el contexto de la máquina el identificador confirmado por el servidor; las pantallas de preferencias posteriores usaban ese identificador para sus actualizaciones de API.
Mantén los componentes de React ajenos al grafo de rutas
Un proveedor envolvía useMachine y exponía currentStep, formData, canGoNext, isSubmitting, handleNext, handlePrevious, handleUpdateFormData y la acción del identificador del comprador. Los componentes de cada paso mostraban sus campos y enviaban actualizaciones sin saber qué ruta iba antes o después.
La máquina controla la navegación. Cada paso de React muestra campos, actualiza el contexto y envía eventos. No usaría XState para un formulario de tres pasos; doce pantallas, validación condicional y transiciones dependientes del servidor hicieron que el grafo explícito compensara la configuración adicional.
Prueba las reglas por separado de las pantallas
La implementación añadió 538 líneas de cobertura unitaria para esquemas y transiciones directas. Esas pruebas enviaban eventos a la máquina y comprobaban el estado sin montar componentes de React. Otras 359 líneas de Playwright recorrían el flujo completo, las reglas de teléfono y correo, la creación del comprador en el servidor y la conservación de datos al retroceder.
- 12 pasos
- siete eventos
- 11 condiciones con nombre para avanzar
- un archivo de máquina que controla la navegación
- pruebas de esquemas y de la máquina separadas de las pruebas de interacción en el navegador

