Formularios de React basados en esquemas: renderizado y validación a partir de JSON Schema
Cómo renderizar formularios validados de React directamente a partir de JSON Schema, manejar $ref, oneOf y ramas if/then, integrar widgets personalizados y evitar trampas comunes de validación.
Un formulario de React escrito a mano suele duplicar un contrato que ya existe. El esquema de solicitudes de la API sabe que email es obligatorio y debe tener el formato de una dirección de correo electrónico, que age es un número entero no negativo, y que role puede ser uno de tres valores. Reescribir esas reglas en JSX, nuevamente en una biblioteca de validación y también en los mensajes de error genera tres fuentes de información para la misma estructura de datos, y estas se desalinean en cuanto cambia el backend.
Esta guía trata al formulario como si proviniera de JSON Schema, utilizando el paquete de código abierto react-simple-schema-form como implementación concreta. Verá cómo $ref, allOf, oneOf y if/then se convierten en campos dinámicos, cómo integrar widgets personalizados y qué comportamientos de validación hacen que un formulario generado parezca creado a mano.
Por qué el esquema debe controlar el formulario
La desviación es predecible: un nuevo campo del backend nunca llega al formulario, y una solicitud como “mostrar solo la dirección de facturación para pagos por factura” se convierte en una bandera useState, un renderizado condicional y un ramal de validación que pierden sincronización meses después.
JSON Schema ya puede expresar cada una de esas reglas: tipos, restricciones, campos obligatorios y lógica condicional. Con frecuencia es el mismo documento con el que tu backend valida las solicitudes y que tu especificación OpenAPI incluye. Si el formulario se genera a partir de él, un cambio en el esquema actualiza la interfaz de usuario y su validación de una sola vez.
Un formulario generado mínimo
react-simple-schema-form acepta un JSON Schema escrito según la versión draft-07 y muestra un formulario validado. Según su documentación, no tiene dependencias en tiempo de ejecución aparte de React 18, incluye sus propios tipos TypeScript y ofrece una hoja de estilo opcional. La instalación se realiza con un único paquete:
npm install react-simple-schema-form
El ejemplo a continuación describe un pequeño objeto de usuario: un nombre, un correo electrónico con format: 'email', una edad entera cuyo valor mínimo es cero, y un rol restringido mediante enum. name y email están marcados como obligatorios. El componente recibe el esquema y una función de callback onSubmit, y nada más.
import { SchemaForm } from 'react-simple-schema-form';
import 'react-simple-schema-form/styles.css';
const schema = {
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
email: { type: 'string', format: 'email', title: 'Email' },
age: { type: 'integer', minimum: 0, title: 'Age' },
role: { type: 'string', enum: ['Admin', 'Editor', 'Viewer'], title: 'Role' },
},
required: ['name', 'email'],
};
<SchemaForm schema={schema} onSubmit={(data) => save(data)} />
A partir de ese esquema, la biblioteca muestra un campo de texto, un campo de correo electrónico, un campo numérico y un selector para el enum, marca los campos obligatorios, muestra errores en línea y llama a onSubmit solo cuando los datos son válidos. El componente funciona en ambos modos de React: se pueden pasar value y onChange para controlarlo, o defaultValue para permitir que gestione su propio estado.
Cualquier generador maneja un objeto plano como este; la verdadera prueba están en los esquemas anidados y ramificados.
Manejo de esquemas que no son planos
Los esquemas de producción reutilizan definiciones, componen fragmentos y se ramifican según los datos. La biblioteca resuelve todo esto en relación con los datos del formulario actual antes de cada renderizado, de modo que cada campo solo vea un esquema plano.
Reutilización de definiciones con $ref y allOf
Una definición compartida como address puede ser referenciada en dos lugares y se mostrará como dos secciones independientes. Las palabras clave colocadas junto a $ref sobrescriben la definición referenciada, por lo que { "$ref": "#/definitions/address", "title": "Shipping address" } genera un bloque de dirección etiquetado como “Dirección de envío”. Con allOf, las partes se fusionan en profundidad: las propiedades anidadas se combinan de forma recursiva y los arrays required se unen formando su unión.
Tratar oneOf como una unión discriminada
Muchos generadores tienen dificultades con oneOf. El patrón que funciona bien es una unión discriminada: cada ramificación asigna un campo compartido a un valor fijo mediante const, y el formulario utiliza ese campo para seleccionar la ramificación activa.
En el esquema de pago a continuación, method es un enum que puede ser card o bank. La primera rama establece method en card y requiere un campo number; la segunda lo establece en bank y requiere un campo iban.
{
"type": "object",
"properties": { "method": { "type": "string", "enum": ["card", "bank"] } },
"required": ["method"],
"oneOf": [
{ "title": "Card", "properties": { "method": { "const": "card" }, "number": { "type": "string" } }, "required": ["number"] },
{ "title": "Bank", "properties": { "method": { "const": "bank" }, "iban": { "type": "string" } }, "required": ["iban"] }
]
}
Cuando se cambia method de card a bank, el campo del número de tarjeta se reemplaza por el campo IBAN. En su código no hay estado de componentes ni JSX condicional; solo el esquema provoca el cambio. Un oneOf cuyas ramas contienen únicamente un const se renderiza como un select etiquetado.
Secciones condicionales con if/then/else y dependencies
Las palabras clave condicionales se vuelven a evaluar cada vez que cambian los datos, incluso con cada tecla pulsada. Una solución útil es incluir una sección opcional que solo se valida cuando el usuario la activa. El fragmento a continuación define un objeto schedule con una bandera booleana enabled (con valor predeterminado de false) y dos campos para los días. La cláusula if se cumple cuando enabled es true, y la cláusula then hace que monday y tuesday sean obligatorios en ese caso.
"schedule": {
"type": "object",
"properties": {
"enabled": { "type": "boolean", "title": "Enable schedule", "default": false },
"monday": { "type": "string", "title": "Monday" },
"tuesday": { "type": "string", "title": "Tuesday" }
},
"if": { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
"then": { "required": ["monday", "tuesday"] }
}
Cuando la opción está desactivada, no se requiere nada dentro de la sección y no se bloquea el envío. Cuando está activada, ambos campos de fecha reciben marcadores de obligatoriedad y el formulario no puede enviarse hasta que se completen. Dado que la opción forma parte de los datos y no del estado local de la interfaz, el servidor puede validar el mismo contenido con el mismo esquema y llegar a la misma conclusión.
La línea "required": ["enabled"] dentro de if es fácil de eliminar pero esencial mantenerla. En JSON Schema, properties solo restringe las claves que están presentes. Por lo tanto, un objeto sin la clave enabled cumple con { "properties": { "enabled": { "const": true } } }, se activa la rama then y los días se vuelven obligatorios aunque esa sección nunca estuvo habilitada. Requerir dicha clave dentro de la condición cierra esa brecha.
Elegir y personalizar widgets
Un formulario generado solo es práctico si se puede controlar qué campo de entrada utiliza cada uno. La biblioteca mantiene un registro de widgets integrados, incluidos text, email, number, select, radio, checkboxes, textarea y date, y ofrece tres formas de asignarlos:
- Una propiedad
uiSchemaindexada por ruta, con soporte para expresiones globales.tags.*apunta a cada elemento de un array, y**.postalCodeapunta a cada código postal en cualquier profundidad, incluso dentro de un$refutilizado en dos lugares. Cuando varias claves coinciden, gana la más específica.
ui:*, y un padre puede albergar un uiSchema anidado referenciado por los nombres de los hijos, de modo que quien haga referencia a una definición compartida pueda rediseñar sus hijos.resolveWidget para elecciones basadas en reglas, como “cada número entero con format: epoch utiliza el widget epoch”. Recibe el esquema completamente resuelto y puede devolver ya sea el nombre de un widget o un componente.El orden de precedencia está fijado: el uiSchema de la aplicación tiene prioridad sobre las sugerencias incrustadas en el esquema, estas a su vez tienen prioridad sobre las reglas de resolveWidget, y estas sobre los valores predeterminados. Esa previsibilidad es importante cuando otro equipo proporciona el esquema: el cliente siempre puede sobrescribir sus sugerencias.
Escribiendo un widget personalizado
Un widget es un componente que recibe el valor actual y una función de callback onChange, además de propiedades como id, required, disabled y onBlur. El ejemplo a continuación almacena una marca de tiempo en segundos Unix, pero muestra al usuario un selector nativo datetime-local. Convierte los segundos en una cadena de fecha para su visualización, y al cambiar el valor vuelve a analizar la entrada, dividiendo los milisegundos entre 1000 y pasando undefined cuando la entrada está vacía o es inválida. El widget se registra con el nombre epoch y se asigna al campo startsAt a través de uiSchema.
import type { Widget } from 'react-simple-schema-form';
const EpochWidget: Widget<number | undefined> = ({ id, value, onChange, onBlur, required, disabled }) => (
<input
type="datetime-local"
id={id}
required={required}
disabled={disabled}
value={value === undefined ? '' : new Date(value * 1000).toISOString().slice(0, 16)}
onBlur={onBlur}
onChange={(e) => {
const ms = new Date(e.target.value).getTime();
onChange(Number.isNaN(ms) ? undefined : Math.floor(ms / 1000));
}}
/>
);
<SchemaForm schema={schema} widgets={{ epoch: EpochWidget }} uiSchema={{ startsAt: { widget: 'epoch' } }} />
El esquema indica integer, el usuario ve un selector, y los datos almacenan segundos Unix. Una advertencia: toISOString() genera tiempo en UTC, mientras que un campo de tipo datetime-local y new Date(e.target.value) funcionan ambos con la zona horaria local del usuario. Fuera de UTC, la hora mostrada se desplaza según el desfase horario y cada edición modifica el valor almacenado. Formatee el valor mostrado a partir de las partes de la fecha local para que ambas direcciones coincidan.
Un widget también puede contener un objeto o array completo, recibiendo todo el valor junto con cada error anidado y renderizando sus elementos hijos a través del componente exportado <Field>. Así es como la sección de horarios obtiene su comportamiento de activación y ocultamiento sin que la biblioteca sepa nada sobre los horarios.
Si un esquema hace referencia a un widget que nunca fue registrado, la biblioteca registra una sola advertencia y recurre al campo de entrada predeterminado. Un error tipográfico en un esquema proporcionado por otro equipo debe causar una caída suave en lugar de hacer que la página se cierre.
Validación que cumple con las expectativas del usuario
El validador integrado es pequeño y no depende de otras bibliotecas; la mayor parte de su diseño se centra en determinar cuándo informar sobre errores y no en si estos existen.
Mostrar los errores en el momento adecuado
Los errores aparecen después de que el usuario abandone un campo, o todos juntos tras intentar enviar el formulario, y nunca en la primera carga de la página. Cuando el envío falla, el foco se desplaza al primer campo inválido.
Tratar los objetos opcionales no modificados como ausentes
Para mostrar los campos de entrada para objetos anidados, el formulario los inicializa con {}. Un validador ingenuo exigiría entonces street y city para una dirección opcional a la que el usuario nunca accedió. La solución consiste en tratar un objeto opcional cuyos valores están todos vacíos como si no existiera, de modo que no se generen errores. Un objeto obligatorio siempre se valida, y muestra una lista de los elementos faltantes en lugar de un mensaje vago como “Se requiere dirección”.
Errores del atributo oneOf en la rama activa
Cuando ninguna rama oneOf se valida, un mensaje genérico como “los datos deben coincidir exactamente con un esquema” resulta inútil para el usuario. En su lugar, el validador determina a qué rama pertenecen los datos, comparando los discriminadores y tipos pero ignorando la opción required, y reporta los errores a nivel de campo de esa rama. En el caso de un pago con method: card pero sin número de tarjeta, el error aparece en el campo del número de tarjeta, donde es donde el usuario buscará.
Nunca permita que los campos ocultos impidan el envío
Los valores incompletos o parcialmente introducidos en una sección que ha sido desactivada no deberían causar un fallo en la verificación pattern que el usuario no pueda ver. La regla está en el esquema, pero la solución reside en el widget: este limpia la sección cuando está desactivada, y la propiedad errors indica al widget qué errores existen dentro de la parte que oculta.
Reutilice las reglas fuera de React
El validador también se exporta por separado. validate(schema, data) devuelve una lista de entradas { path, keyword, message }, por lo que las mismas reglas pueden aplicarse en un servicio de Node.js, en una prueba unitaria o antes de que se muestre cualquier contenido. Para una alternativa basada en TypeScript, consulte cómo compartir un esquema Zod entre React y Node.
Documentación dirigida a asistentes de codificación
A menudo, los formularios se crean con la ayuda de un asistente de codificación basado en IA, por lo que el paquete incluye documentación dirigida tanto a máquinas como a personas:
- Un archivo de habilidades del Agente en
skills/react-simple-schema-form/SKILL.mddentro del paquete npm, que las herramientas que soportan el formato de habilidades del Agente pueden cargar desdenode_modules. Cubre la API, la precedencia de los widgets, las recetas mencionadas anteriormente y los problemas conocidos; su tamaño es de aproximadamente 7 kB en el momento de escribir esto. llms.txtyllms-full.txten el sitio de demostración, que agrupan el README, las habilidades y cada esquema de ejemplo en un único archivo que se puede pegar en un chat o indexar por un servidor MCP de documentación.- JSDoc con ejemplos en cada exportación, de modo que al pasar el cursor del editor sobre las declaraciones de tipo se explica su uso.
- Un archivo
context7.jsonpara que el repositorio se indexe correctamente en Context7.
Esto no hará que un modelo elija la biblioteca, pero aumenta las posibilidades de que el primer intento del asistente funcione; es una práctica que también vale la pena adoptar para las bibliotecas internas.
Probándolo
La demostración en vivo muestra un editor de esquemas junto al formulario generado, con datos en tiempo real y errores debajo. Incluye ejemplos para $ref, allOf, oneOf, if/then/else, dependencies y selección de widgets. El paquete está publicado en npm, y el código fuente así como el tracker de problemas se encuentran en GitHub. Es un proyecto reciente, así que pruébelo con sus propios esquemas antes de confiar en él.
Puntos clave
- Si una API ya publica un JSON Schema, generar el formulario a partir de él elimina las reglas duplicadas y mantiene la validación en la interfaz de usuario y en el servidor alineada.
- Resuelve
$ref,allOf,oneOfy las condiciones con datos en tiempo real para que cada campo reciba un esquema simplificado. - Modela los formularios con variantes como uniones discriminadas utilizando
const, y siempre agrega la propiedadrequireddentro de las cláusulasif. - Mantén la posibilidad de sobrescribir la selección de widgets con un orden claro de prioridad, especialmente para esquemas gestionados por otro equipo.
- Los formularios generados de calidad dependen del momento de la validación: informa al perder el foco o al enviar, ignora los objetos opcionales sin modificar y dirige los errores de
oneOfal branch activo.
Lecturas relacionadas
- Compartir un esquema Zod entre tu frontend React y backend Node — Aprende cómo un único esquema Zod puede validar formularios de React, respuestas de API, cuerpos de solicitudes de Express y variables de entorno al mismo tiempo que genera tipos TypeScript correspondientes.
- Rastreando una llamada a setState de React desde la cola de actualizaciones hasta el compromiso del DOM — Sigue paso a paso una actualización de estado en React a través de la cola de actualizaciones del Hook, el programador, la fase de renderizado, la reconciliación y el compromiso, y comprende por qué el estado nunca cambia de inmediato.