Tutorial

Generative UI en React: guía práctica

Cómo implementar componentes de UI generados por IA en aplicaciones React: patrones que funcionan en producción y errores habituales.

A
Alex12 min de lectura

La mayoría de los prototipos GenUI fracasan en estos cinco patrones

Las demos de Generative UI parecen magia. Las aplicaciones GenUI de producción fallan de forma predecible en cinco puntos: selección frágil de herramientas, race conditions durante el streaming, discrepancias de props en tiempo de ejecución, ausencia de fallback cuando el modelo no está disponible, y coste de inferencia descontrolado. Esta guía cubre los cinco patrones que realmente mantienen viva una funcionalidad GenUI más allá de la demo: registro, separación, skeletons, error boundary y estado — más los compromisos que cada uno esconde, y recomendaciones concretas para los dos públicos que habitualmente deciden "¿lanzamos o no?": el engineering manager que elige el stack, y el desarrollador indie que despliega un side-project con presupuesto ajustado.

¿Por qué React para Generative UI?

El modelo de componentes de React encaja perfectamente con Generative UI. Los componentes son componibles, están tipados y pueden renderizarse tanto en el servidor como en el cliente. Cuando un modelo de IA "genera una interfaz", en realidad está seleccionando y componiendo componentes React con props concretas.

Esta guía cubre los patrones que funcionan en producción y los errores que veo cometer a equipos que empiezan a construir interfaces generativas. Se asume que ya tienes Next.js configurado y conoces los fundamentos de Vercel AI SDK — esto es la capa práctica sobre esa base.

Patrón 1: el registro de herramientas

La base de cualquier sistema de Generative UI mantenible es un registro explícito y centralizado de los componentes que la IA puede usar. No disperses las definiciones de herramientas por distintos server actions.

// lib/genui-registry.ts
import { z } from 'zod';
import { MetricCard } from '@/components/metric-card';
import { DataTable } from '@/components/data-table';
import { BarChart } from '@/components/bar-chart';
import { AlertBanner } from '@/components/alert-banner';
import { LineChart } from '@/components/line-chart';

export const tools = {
  metricCard: {
    description: 'Display a single KPI metric with a trend indicator. Use for scalar values like revenue, user count, or conversion rate.',
    parameters: z.object({
      label: z.string().describe('The metric name, e.g. "Monthly Revenue"'),
      value: z.string().describe('The formatted value, e.g. "$12,400"'),
      change: z.number().describe('Percentage change vs. previous period'),
      period: z.string().describe('The comparison period, e.g. "vs last month"'),
    }),
    component: MetricCard,
  },
  dataTable: {
    description: 'Display tabular data with sortable columns. Use when showing lists of items with multiple attributes.',
    parameters: z.object({
      columns: z.array(z.object({
        key: z.string(),
        label: z.string(),
        numeric: z.boolean().optional(),
      })),
      rows: z.array(z.record(z.string())),
      caption: z.string().optional(),
    }),
    component: DataTable,
  },
  barChart: {
    description: 'Display a bar chart for categorical comparisons. Use when comparing values across discrete categories.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ label: z.string(), value: z.number() })),
      yAxisLabel: z.string().optional(),
    }),
    component: BarChart,
  },
  lineChart: {
    description: 'Display a line chart for time-series data. Use when showing trends over time.',
    parameters: z.object({
      title: z.string(),
      data: z.array(z.object({ date: z.string(), value: z.number() })),
      unit: z.string().optional(),
    }),
    component: LineChart,
  },
  alertBanner: {
    description: 'Display an important notice, warning, or success message. Use sparingly for genuinely important information.',
    parameters: z.object({
      type: z.enum(['info', 'warning', 'error', 'success']),
      title: z.string(),
      message: z.string(),
    }),
    component: AlertBanner,
  },
};

export type ToolName = keyof typeof tools;

Idea clave: el campo description es lo que la IA lee para decidir qué componente usar. Escribe las descripciones para la IA, no para las personas. Indica con claridad cuándo es apropiado cada componente y cuándo no.

Fíjate en que lineChart dice "time-series" (series temporales) y barChart — "categorical" (datos categóricos). Sin esta distinción, la IA elegirá entre ellos de forma aleatoria. Cuanto más precisas sean las descripciones, mejor será la selección de componentes.

Cuándo este patrón no funciona. Un registro centralizado asume que un solo equipo posee el catálogo. Si tres equipos de producto quieren sus propios componentes, el registro se convierte en un cuello de botella de coordinación — cada nueva herramienta pasa por un PR al equipo de plataforma. La alternativa es un registro federado por superficie de producto, a costa de descripciones duplicadas y calidad divergente. La centralización es para un solo producto; la federación, para una plataforma que sirve a muchos. Consulta la documentación oficial de streamUI en Vercel AI SDK para la API base.

Patrón 2: separar el registro del streaming

Mantén la definición del registro separada de la llamada a streamUI. Esto permite reutilizar las definiciones de herramientas en múltiples server actions y testear el registro de forma aislada.

// lib/stream-with-tools.ts
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { tools } from './genui-registry';

// Convert registry format to streamUI format
function buildStreamTools(toolNames: ToolName[]) {
  return Object.fromEntries(
    toolNames.map((name: ToolName) => [
      name,
      {
        description: tools[name].description,
        parameters: tools[name].parameters,
        generate: async function* (params: unknown) {
          yield <ToolSkeleton name={name} />;
          const Component = tools[name].component;
          // Protección contra null: una entrada del registro puede ser incorrecta
          // o haberse recargado en hot reload en mitad de una solicitud.
          if (!Component) {
            return <GenUIFallback error={new Error(`Missing component for tool: ${name}`)} resetErrorBoundary={() => {}} />;
          }
          return <Component {...(params as Record<string, unknown>)} />;
        },
      },
    ])
  );
}

// Server action para un dashboard de datos
export async function generateDashboard(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a data analyst assistant. Display information using the appropriate visualization tool.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'dataTable', 'barChart', 'lineChart', 'alertBanner']),
  });
  return result.value;
}

// Server action para una vista resumen (menos herramientas = mejor foco)
export async function generateSummary(query: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a concise assistant. Show a summary with key metrics only.',
    prompt: query,
    tools: buildStreamTools(['metricCard', 'alertBanner']),
  });
  return result.value;
}

Pasar a cada server action un subconjunto de herramientas es importante. Un conjunto limitado de herramientas mejora la calidad de las decisiones de la IA. No le des a la IA 20 herramientas donde bastan 5.

Cuándo este patrón no funciona. Separar el registro del streaming añade una capa de indirección extra. Para un prototipo de una sola pantalla con una sola herramienta, esto no es arquitectura — son costes adicionales. Mantén la definición de la herramienta inline hasta que aparezca un segundo server action.

Patrón 3: streaming con skeletons

Nunca muestres una pantalla en blanco mientras la IA genera la respuesta. Muestra estados de carga skeleton que se correspondan con el resultado esperado. La continuidad visual reduce drásticamente la latencia percibida.

// components/tool-skeleton.tsx
import { ToolName } from '@/lib/genui-registry';

const SKELETON_HEIGHTS: Record<ToolName, string> = {
  metricCard: 'h-28',
  dataTable: 'h-48',
  barChart: 'h-64',
  lineChart: 'h-64',
  alertBanner: 'h-16',
};

export function ToolSkeleton({ name }: { name: ToolName }) {
  return (
    <div
      className={`animate-pulse rounded-lg bg-muted ${SKELETON_HEIGHTS[name] ?? 'h-32'} w-full`}
      aria-label="Loading..."
      aria-busy="true"
    />
  );
}

Para skeletons más precisos, replica la estructura interna del componente:

export function MetricCardSkeleton() {
  return (
    <div className="rounded-lg border bg-card p-6">
      <div className="h-4 w-24 animate-pulse rounded bg-muted" />
      <div className="mt-3 h-8 w-32 animate-pulse rounded bg-muted" />
      <div className="mt-2 h-3 w-16 animate-pulse rounded bg-muted" />
    </div>
  );
}

Cuando el skeleton replica la estructura interna del componente, la transición del skeleton al componente cargado es fluida: sin layout shift, sin parpadeos.

Cuándo este patrón no funciona. Los skeletons personalizados para cada componente duplican la superficie de mantenimiento: cada actualización del componente requiere actualizar el skeleton. Para herramientas internas de bajo tráfico donde la latencia percibida no impacta en el negocio, un rectángulo gris genérico es suficiente. Reserva los skeletons manuales para las superficies que ven los usuarios finales en cada sesión.

Patrón 4: error boundary para UI generado

Los componentes generados fallan de formas distintas a los escritos a mano. La IA puede pasar una cadena numérica donde se espera un número, un valor negativo donde solo se admiten positivos, o un array vacío a un componente que necesita al menos un elemento.

Envuelve siempre la salida generada en un error boundary:

// components/safe-genui.tsx
'use client';

import { ErrorBoundary } from 'react-error-boundary';

function GenUIFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
      <p className="text-sm font-medium text-destructive">
        This component could not render
      </p>
      <p className="mt-1 text-xs text-muted-foreground">{error.message}</p>
      <button
        onClick={resetErrorBoundary}
        className="mt-2 text-xs underline text-muted-foreground"
      >
        Try again
      </button>
    </div>
  );
}

export function SafeGenUI({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary FallbackComponent={GenUIFallback}>
      {children}
    </ErrorBoundary>
  );
}

Envuelve cada fragmento de salida generada en <SafeGenUI>. Un error de renderizado en un componente no debe romper toda la respuesta. La mecánica base la proporciona la biblioteca react-error-boundary.

Cuándo este patrón no funciona. El error boundary suprime las excepciones. Si no envías error.message a un sistema de monitorización (Sentry, GlitchTip, Datadog), el mismo bug puede estar disparándose en silencio en producción durante semanas. Un boundary sin logging es peor que no tener boundary, porque enmascara el síntoma.

Patrón 5: gestión de estado para interacciones generadas

Los componentes generados por la IA a menudo necesitan ser interactivos: una tabla con ordenación, un gráfico con tooltips, un formulario que envía datos. Esa interactividad vive dentro del propio componente y no requiere soluciones especiales.

Requiere atención especial el caso en que el UI generado necesita afectar al estado de la aplicación fuera del propio componente:

// Usando React context para que los componentes generados interactúen con la app
export const AppStateContext = createContext<{
  onDataSelected: (data: unknown) => void;
  onActionTriggered: (action: string, params: unknown) => void;
} | null>(null);

// En tu componente generado
function DataTable({ columns, rows }: DataTableProps) {
  const appState = useContext(AppStateContext);

  function handleRowClick(row: Record<string, string>) {
    appState?.onDataSelected(row);
  }

  return (
    <table>
      {/* ... */}
      {rows.map((row, i) => (
        <tr key={i} onClick={() => handleRowClick(row)} className="cursor-pointer hover:bg-muted">
          {/* ... */}
        </tr>
      ))}
    </table>
  );
}

Diseña los componentes generados con una API clara para las interacciones externas. Pasa las props de callback a través del contexto, no importes el estado global directamente — los componentes generados deben ser portables.

Cuándo este patrón no funciona. La vinculación al contexto hace que los componentes generados no sean testables de forma aislada: cada historia de Storybook necesitará montar el provider. Si el estado externo solo lo necesitan uno o dos componentes, es más honesto pasar props directamente. Pasa al contexto cuando tres o más componentes comparten la misma interfaz de salida.

Matriz de selección de patrones

Para el engineering manager que decide qué patrones introducir primero, los compromisos son los siguientes:

PatrónCoste de implementaciónBeneficioPuedes omitirlo si
Registro1 díaCrece con el catálogo; imprescindible para la testabilidadTienes una sola herramienta para siempre
Separar registro y streaming2 horasReutilización entre superficies; tests unitarios aisladosUn solo server action
Skeletons1 día por componente (personalizados), 1 hora (genérico)Latencia percibida durante el streaming; necesarios para modelos lentosHerramientas internas sin SLA
Error boundary2 horas + integración con loggingImprescindible para producción; sin él cualquier bug en las props = pantalla blancaNunca — siempre inclúyelo en el lanzamiento
Estado externo0,5–2 díasNecesario para GenUI que lanza acciones en la appDisplays de solo lectura

El error boundary es la única línea incondicional. Los otros cuatro se ordenan por tamaño de equipo: el desarrollador solo añade los skeletons al final; un equipo de 5 personas lanza el registro el primer día, porque el coste de coordinación sin él supera el coste de construirlo.

Coste de propiedad por tamaño de equipo

Estimación aproximada del coste total de propiedad a 12 meses con inferencia a nivel de GPT-4o y un producto con tráfico moderado (10k generaciones al día). Son estimaciones de primer orden — calibra con tu telemetría antes de comprometerte.

Tamaño del equipoDesarrollo (semanas de ingeniero)Inferencia ($/mes)Operaciones + guardia (horas de ingeniero/mes)
Solo (indie)2–3 semanas$150–$4004–8
Equipo pequeño (3–5)4–6 semanas$400–$1.2008–16
Equipo mediano (10+)8–12 semanas$1.200–$5.000+16–40

La inferencia domina a escala. La palanca más barata es reducir el número de herramientas por server action (Patrón 2) y cachear los prompts idénticos; la segunda — enrutar las solicitudes simples a un modelo más pequeño.

Hoja de ruta de implementación en equipo

Semanas 1–2: despliega el Patrón 4 (error boundaries) y el Patrón 1 (registro) con dos o tres herramientas bajo feature flag al 5% de los usuarios. Semanas 3–4: añade el Patrón 3 (skeletons) y el Patrón 2 (separación); amplía al 25%. Semanas 5–8: añade el Patrón 5 (estado); despliega al 100%. En cada puerta mantén el rollout hasta que la latencia p95, la tasa de errores y el coste de inferencia por sesión cumplan los SLOs publicados. No añadas nuevas herramientas al registro hasta que el primer conjunto esté estabilizado.

Desplegar tu app GenUI (escenario indie)

Si eres un desarrollador solo y quieres lanzar una funcionalidad GenUI este fin de semana, aquí está la ruta más corta razonable:

  1. Empieza con create-next-app y App Router. Instala ai, @ai-sdk/openai, zod y react-error-boundary.
  2. Omite el Patrón 2 en la primera versión — define dos herramientas inline directamente en el server action.
  3. Usa el "rectángulo gris genérico" del Patrón 3, no las variantes personalizadas. Los personalizados van cuando la funcionalidad ya tenga usuarios.
  4. Envuelve el stream en <SafeGenUI> del Patrón 4. Esto no se puede omitir.
  5. Despliega en el plan gratuito o Pro de Vercel. Añade OPENAI_API_KEY a las variables de entorno. El primer despliegue es un git push.
  6. Pon un límite estricto de gasto en la clave de OpenAI (el dashboard de OpenAI soporta límites mensuales) para que un bucle infinito no vacíe el presupuesto durante la noche.

Estimación de coste para un proyecto hobby (1.000 generaciones al mes): aproximadamente $5–$15 en inferencia, $0 en hosting en el tier hobby de Vercel, $0 en monitorización con los logs integrados de Vercel. Por nuestra estimación, la primera factura apreciable llega alrededor de las 50.000 generaciones al mes; ahí es cuando el Patrón 2 (separación de server actions) y el caché de prompts empiezan a rentabilizarse.

Registro mínimo para volumen indie — un server action con una herramienta y su skeleton, listo para copiar:

// app/actions.tsx
'use server'
import { streamUI } from 'ai/rsc'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
import { MetricCard } from '@/components/metric-card'
import { Skeleton } from '@/components/skeleton'

const metricSchema = z.object({
  value: z.number().describe('valor numérico actual de la métrica'),
  label: z.string().describe('nombre legible de la métrica'),
  delta: z.number().describe('cambio respecto al período anterior en porcentaje'),
})

export async function generateUI(prompt: string) {
  const result = await streamUI({
    model: openai('gpt-4o-mini'),
    prompt,
    tools: {
      metricCard: {
        description: 'Muestra una métrica clave con su delta',
        parameters: metricSchema,
        generate: async function* (p: z.infer<typeof metricSchema>) {
          yield <Skeleton className="h-28 rounded bg-muted" />
          return <MetricCard {...p} period="vs last month" />
        },
      },
    },
  })
  return result.value
}

Y la llamada del cliente en un único formulario:

// app/page.tsx
'use client'
import { useState } from 'react'
import { generateUI } from './actions'

export default function Page() {
  const [ui, setUI] = useState<React.ReactNode>(null)
  return (
    <form action={async (formData) => {
      setUI(await generateUI(formData.get('q') as string))
    }}>
      <input name="q" />
      <button>Generar</button>
      <div>{ui}</div>
    </form>
  )
}

Pasa al conjunto completo de patrones cuando la funcionalidad tenga usuarios de pago o el catálogo de herramientas crezca a tres o más.

Errores frecuentes

Demasiadas herramientas. Si le das a la IA 50 componentes para elegir, las decisiones serán malas. He visto equipos que empezaban con 20+ herramientas y descubrían que la IA elegía sistemáticamente las incorrectas. Empieza con 5–8 herramientas bien descritas y amplía el conjunto solo apoyándote en datos de consultas no atendidas.

Descripciones imprecisas. "Muestra datos" es una descripción inútil. "Muestra datos tabulares con columnas ordenables para listas de elementos con múltiples atributos" explica claramente a la IA cuándo usar esa herramienta.

Sin fallback. Cuando el modelo de IA no está disponible o devuelve un error, el usuario ve un vacío. Para rutas críticas, ten siempre un fallback estático. Si usas Generative UI para un dashboard de datos, prepara una vista estática por defecto para cuando la IA no esté disponible.

Abandonar la validación Zod. La IA a veces pasa props inesperadas: una cadena en lugar de un número, null en lugar de un valor requerido. La validación estricta con Zod captura esto antes de que los datos defectuosos lleguen al componente.

Generación excesiva. No toda interacción necesita Generative UI. Si un componente estático funciona — úsalo. GenUI añade 200–800 ms de latencia y tiene un coste. Úsalo donde la variabilidad realmente aporte valor.

Sin logging de las invocaciones de herramientas. Sin logs sobre qué herramientas elige la IA y qué parámetros pasa, no tienes datos para mejorar. Registra todo desde el primer día. Los patrones que verás después de una semana de uso cambiarán cómo escribes las descripciones de las herramientas.

Checklist de producción

Antes de lanzar Generative UI a producción:

  • Todos los componentes generados envueltos en error boundary
  • Estados de carga skeleton para cada herramienta
  • Fallback estático cuando la IA no está disponible o da error
  • Validación Zod estricta de todos los parámetros de herramientas
  • Logging de tool calls (nombre de la herramienta, parámetros, latencia)
  • Monitorización de latencia (alerta si >2 s hasta el primer componente)
  • Seguimiento del coste de cada inferencia de IA
  • Auditoría de accesibilidad de todas las combinaciones generadas de componentes
  • Pruebas de responsividad de los layouts generados en móvil
  • Rate limiting en el server action

Sobre las pruebas

Las pruebas de Generative UI requieren un enfoque distinto al del UI tradicional. Resumen:

  • Prueba los componentes de forma aislada con tests unitarios estándar — son simplemente componentes React
  • Prueba los esquemas Zod por separado para verificar que aceptan entradas válidas y rechazan las inválidas
  • Para tests de integración con IA, verifica propiedades estructurales (se invocó la herramienta correcta, los parámetros son válidos), no el contenido exacto (temperatura 22°)
  • Mockea la IA en CI y ejecuta los tests de integración reales de IA por la noche

Este tema merece un artículo aparte, y lo hemos escrito: Pruebas de aplicaciones Generative UI.

Alternativas consideradas

Los patrones anteriores asumen Vercel AI SDK con React Server Components. Dos alternativas que conviene conocer antes de comprometerse:

  • Tambo / catálogo de componentes como servicio. Framework de código abierto para UI generado por IA en React (github.com/tambo-ai/tambo, ~11k estrellas en mayo 2026): se lanza más rápido (no hay que escribir el código del registro) y centraliza la calidad de las descripciones. Adecuado cuando la velocidad hasta la primera demo es más importante que el coste unitario a largo plazo.
  • Protocolos JSON declarativos como Thesys C1 (API cerrada) o A2UI v0.9 (especificación abierta de Google, noviembre 2025) desacoplan el modelo de React; cualquier cliente (web, móvil, voz) puede renderizar el mismo payload. Adecuado cuando tienes superficies no-web — a costa de escribir tu propio renderer.
  • JSON puro + dispatcher manual. Sin SDK. Escribes un switch por nombre de herramienta. La opción más barata en volúmenes pequeños, la más difícil de mantener pasadas las cinco herramientas.

El eje de decisión es robustez y portabilidad vs. tiempo de lanzamiento. Para la mayoría de productos solo React, la vía del SDK de este artículo gana; para productos multi-superficie o vendor-neutral, evalúa A2UI.

Lecturas adicionales


¿Estás implementando Generative UI en React? Obtén asesoría experta sobre arquitectura, rendimiento y preparación para producción.

CompartirTwitterLinkedInEmail
reactgenerative-uipatternsimplementation
A

Alex

Ingeniero y Consultor de Generative UI

Ingeniero senior especializado en interfaces con AI y sistemas Generative UI. Ayudando a equipos de producto a lanzar más rápido con el stack GenUI adecuado.

Adelántate en Generative UI

Artículos semanales, actualizaciones de frameworks y guías de implementación prácticas — directamente a tu bandeja de entrada.

Respetamos tu privacidad. Baja en cualquier momento.

¿Necesitas ayuda para implementar lo que acabas de leer?

Reserva una consulta gratuita