Туториал

Generative UI в React: практическое руководство

Как реализовать AI-генерируемые компоненты в React-приложениях: рабочие паттерны и типичные ошибки.

A
Alex12 мин чтения

Большинство GenUI-прототипов проваливаются на этих пяти паттернах

Демо Generative UI выглядят как магия. Продакшен-приложения GenUI ломаются предсказуемо в пяти точках: хрупкий выбор инструментов, гонки во время стриминга, рантайм-расхождения пропсов, отсутствие фолбэка при недоступности модели и неконтролируемая стоимость инференса. Это руководство — про пять паттернов, которые реально удерживают GenUI-фичу живой после демо: реестр, разделение, скелетоны, error boundary и состояние, — плюс компромиссы, которые каждый из них прячет, и конкретные рекомендации для двух аудиторий, обычно решающих «выпускать или нет»: для инженерного менеджера, выбирающего стек, и для indie-разработчика, выкатывающего сайд-проект на ограниченный бюджет.

Почему React подходит для Generative UI?

Компонентная модель React идеально подходит для Generative UI (генеративного UI). Компоненты компонуемы, типизированы и могут отрисовываться как на сервере, так и на клиенте. Когда AI-модель «генерирует UI», она на самом деле выбирает и компонует React-компоненты с конкретными пропсами.

Это руководство охватывает паттерны, которые работают в продакшене, и ошибки, которые я вижу в командах, только начинающих создавать генеративные интерфейсы. Предполагается, что у вас уже настроен Next.js и вы знакомы с основами Vercel AI SDK — это практический слой поверх этого фундамента.

Паттерн 1: реестр инструментов

Основа любой поддерживаемой системы Generative UI — явный централизованный реестр компонентов, доступных AI. Не разбрасывайте определения инструментов по серверным экшенам.

// 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;

Ключевая мысль: поле description — это то, что AI читает, чтобы решить, какой компонент использовать. Пишите описания для AI, а не для людей. Чётко указывайте, когда каждый компонент уместен, а когда — нет.

Обратите внимание: lineChart говорит «time-series» (временные ряды), а barChart — «categorical» (категориальные данные). Без этого разграничения AI будет выбирать между ними случайным образом. Чем точнее описания, тем лучше выбор компонентов.

Когда паттерн не работает. Централизованный реестр предполагает, что каталогом владеет одна команда. Если три продуктовые команды хотят свои компоненты, реестр становится координационным узким местом — каждый новый инструмент идёт через PR в платформенную команду. Альтернатива — федеративный реестр на каждую продуктовую поверхность, ценой дублирующихся описаний и расходящегося качества. Централизация — для одного продукта, федерация — для платформы, обслуживающей множество. См. официальную документацию streamUI в Vercel AI SDK по базовому API.

Паттерн 2: отделение реестра от стриминга

Держите определение реестра отдельно от вызова streamUI. Это позволяет переиспользовать определения инструментов в нескольких серверных экшенах и тестировать реестр изолированно.

// 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;
          // Защита от null: запись в реестре может оказаться некорректной
          // или быть перезагруженной в hot reload посреди запроса.
          if (!Component) {
            return <GenUIFallback error={new Error(`Missing component for tool: ${name}`)} resetErrorBoundary={() => {}} />;
          }
          return <Component {...(params as Record<string, unknown>)} />;
        },
      },
    ])
  );
}

// Server action for a data dashboard
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 for a summary view (fewer tools = better focus)
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;
}

Передавать каждому серверному экшену подмножество инструментов — важно. Ограниченный набор инструментов улучшает качество решений AI. Не давайте AI 20 инструментов там, где хватит 5.

Когда паттерн не работает. Разделение реестра и стриминга добавляет лишний слой косвенности. Для прототипа на один экран с одним инструментом это не архитектура, а накладные расходы. Держите определение инструмента инлайн, пока не появится второй серверный экшен.

Паттерн 3: стриминг со скелетонами

Никогда не показывайте пустой экран, пока AI генерирует ответ. Показывайте скелетные состояния загрузки, соответствующие ожидаемому результату. Визуальная непрерывность резко снижает воспринимаемую задержку.

// 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"
    />
  );
}

Для более точных скелетонов повторяйте внутреннюю структуру компонента:

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>
  );
}

Когда скелетон повторяет внутреннюю структуру компонента, переход от скелетона к загруженному компоненту становится плавным: никакого сдвига макета, никакого мерцания.

Когда паттерн не работает. Кастомные скелетоны под каждый компонент удваивают поверхность поддержки: каждое обновление компонента требует обновления скелетона. Для внутренних низкотрафиковых инструментов, где воспринимаемая задержка не влияет на бизнес, обычная серая плашка достаточна. Берегите ручные скелетоны для поверхностей, которые видят конечные пользователи в каждой сессии.

Паттерн 4: error boundary для генерируемого UI

Генерируемые компоненты отказывают иначе, чем написанные вручную. AI может передать числовую строку там, где ожидается число, отрицательное значение там, где допустимы только положительные, или пустой массив компоненту, которому нужен хотя бы один элемент.

Всегда оборачивайте генерируемый вывод в 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>
  );
}

Оборачивайте каждый фрагмент генерируемого вывода в <SafeGenUI>. Ошибка рендеринга в одном компоненте не должна ломать весь ответ. Базовую механику обеспечивает библиотека react-error-boundary.

Когда паттерн не работает. Error boundary глотает исключения. Если вы не отправляете error.message в систему мониторинга (Sentry, GlitchTip, Datadog), один и тот же баг будет неделями молча срабатывать в продакшене. Boundary без логирования хуже отсутствия boundary, потому что он маскирует симптом.

Паттерн 5: управление состоянием для генерируемых взаимодействий

Компоненты, генерируемые AI, часто должны быть интерактивными: таблица с сортировкой, график с тултипами, форма, отправляющая данные. Эта интерактивность живёт внутри самого компонента и не требует особых решений.

Отдельного внимания требует случай, когда генерируемому UI нужно влиять на состояние приложения за пределами самого компонента:

// Using React context to let generated components interact with the app
export const AppStateContext = createContext<{
  onDataSelected: (data: unknown) => void;
  onActionTriggered: (action: string, params: unknown) => void;
} | null>(null);

// In your generated component
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>
  );
}

Проектируйте генерируемые компоненты с чётким API для внешних взаимодействий. Передавайте коллбэк-пропсы через контекст, а не импортируйте глобальное состояние напрямую — генерируемые компоненты должны быть переносимыми.

Когда паттерн не работает. Привязка к контексту делает генерируемые компоненты непригодными для изолированного тестирования: теперь в каждой Storybook-истории нужно монтировать провайдер. Если внешнее состояние нужно одному-двум компонентам, честнее обойтись прокидыванием пропсов. К контексту переходите, когда три и более компонентов делят один и тот же исходящий интерфейс.

Матрица выбора паттернов

Для инженерного менеджера, выбирающего, какие паттерны вводить первыми, компромиссы выглядят так:

ПаттернСтоимость внедренияВыгодаМожно пропустить, если
Реестр1 деньРастёт с каталогом; обязателен для тестируемостиУ вас один инструмент навсегда
Разделение реестра и стриминга2 часаПереиспользование между поверхностями; изолированные юнит-тестыОдин серверный экшен
Скелетоны1 день на компонент (кастомные), 1 час (универсальные)Воспринимаемая задержка при стриминге; нужны для медленных моделейВнутренние инструменты без SLA
Error boundary2 часа + интеграция с логированиемОбязателен для продакшена; без него любой баг в пропсах = белый экранНикогда — выпускайте всегда
Внешнее состояние0.5–2 дняНужно для GenUI, который запускает действия в приложенииДисплеи только для чтения

Error boundary — единственная безусловная строка. Остальные четыре сортируются по размеру команды: соло-разработчик добавляет скелетоны последними; команда из 5 человек выпускает реестр в первый день, потому что координационная цена его отсутствия выше, чем цена постройки.

Стоимость владения по размеру команды

Грубая прикидка совокупной стоимости владения за 12 месяцев при инференсе уровня GPT-4o и продукте с умеренным трафиком (10k генераций в день). Это оценки первого порядка — калибруйте по своей телеметрии перед коммитом.

Размер командыРазработка (инж.-недель)Инференс ($/мес)Эксплуатация + дежурства (инж.-часов/мес)
Соло (indie)2–3 недели$150–$4004–8
Малая команда (3–5)4–6 недель$400–$1,2008–16
Средняя команда (10+)8–12 недель$1,200–$5,000+16–40

Инференс доминирует при масштабе. Самый дешёвый рычаг — уменьшить число инструментов на серверный экшен (Паттерн 2) и кешировать одинаковые промпты; второй — направлять простые запросы в меньшую модель.

Roadmap внедрения в команде

Недели 1–2: выпускайте Паттерн 4 (error boundaries) и Паттерн 1 (реестр) с двумя-тремя инструментами под feature flag на 5% пользователей. Недели 3–4: добавляйте Паттерн 3 (скелетоны) и Паттерн 2 (разделение); расширяйте до 25%. Недели 5–8: добавляйте Паттерн 5 (состояние); катите на 100%. На каждом гейте удерживайте раскатку, пока p95-задержка, доля ошибок и стоимость инференса за сессию не уложатся в опубликованные SLO. Не добавляйте новые инструменты в реестр, пока первый набор не стабилизирован.

Деплой вашего GenUI-приложения (indie-сценарий)

Если вы соло-разработчик и хотите выпустить GenUI-фичу в эти выходные, вот самый короткий разумный маршрут:

  1. Стартуйте с create-next-app и App Router. Установите ai, @ai-sdk/openai, zod и react-error-boundary.
  2. Пропустите Паттерн 2 в первой версии — определите два инструмента инлайн прямо в серверном экшене.
  3. Используйте универсальный «серый прямоугольник» из Паттерна 3, а не кастомные варианты. Кастомные выпускайте, когда у фичи появятся пользователи.
  4. Оберните стрим в <SafeGenUI> из Паттерна 4. Это нельзя пропустить.
  5. Деплойте на бесплатном или Pro-тарифе Vercel. Добавьте OPENAI_API_KEY в переменные окружения. Первый деплой — это git push.
  6. Поставьте жёсткий лимит расходов на OpenAI-ключ (дашборд OpenAI поддерживает месячные лимиты), чтобы цикл-в-цикле не выкачал бюджет за ночь.

Прикидка стоимости для хобби-проекта (1000 генераций в месяц): примерно $5–$15 на инференс, $0 на хостинг на Vercel hobby tier, $0 на мониторинг через встроенные логи Vercel. По нашей оценке, первый ощутимый счёт начинается при ~50 тыс. генераций в месяц; именно тогда Паттерн 2 (разделение серверных экшенов) и кеширование промптов начинают окупаться.

Упрощённый реестр для indie-объёма — серверный экшен с одним инструментом и скелетоном, готов к копированию:

// 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('текущее числовое значение метрики'),
  label: z.string().describe('человекочитаемое имя метрики'),
  delta: z.number().describe('изменение к предыдущему периоду в процентах'),
})

export async function generateUI(prompt: string) {
  const result = await streamUI({
    model: openai('gpt-4o-mini'),
    prompt,
    tools: {
      metricCard: {
        description: 'Показывает одну ключевую метрику с дельтой',
        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
}

И клиентский вызов в одну форму:

// 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>Сгенерировать</button>
      <div>{ui}</div>
    </form>
  )
}

Переходите к полному набору паттернов, когда у фичи появятся платящие пользователи или каталог инструментов вырастет до трёх и больше.

Типичные ошибки

Слишком много инструментов. Если дать AI 50 компонентов на выбор, решения будут неудовлетворительными. Я видел команды, начинавшие с 20+ инструментов и обнаруживавшие, что AI стабильно выбирает не те. Начните с 5–8 хорошо описанных инструментов и расширяйте набор только опираясь на данные о незакрытых запросах.

Расплывчатые описания. «Отображает данные» — бесполезное описание. «Отображает табличные данные с сортируемыми столбцами для списков элементов с несколькими атрибутами» точно объясняет AI, когда использовать этот инструмент.

Отсутствие фолбэка. Когда AI-модель недоступна или возвращает ошибку, пользователь видит пустоту. Для критических путей всегда предусматривайте статический фолбэк. Если вы используете Generative UI для дашборда с данными, подготовьте статическое представление по умолчанию на случай недоступности AI.

Отказ от Zod-валидации. AI время от времени передаёт неожиданные пропсы: строку вместо числа, null вместо обязательного значения. Строгая Zod-валидация перехватывает это до того, как дефектные данные достигнут компонента.

Избыточная генерация. Не каждому взаимодействию нужен Generative UI. Если статический компонент справляется — используйте его. GenUI добавляет 200–800 мс задержки и стоит денег. Применяйте его там, где вариативность действительно ценна.

Отсутствие логирования вызовов инструментов. Без логов о том, какие инструменты выбирает AI и какие параметры передаёт, у вас нет данных для улучшения. Логируйте всё с первого дня. Паттерны, которые вы увидите через неделю использования, изменят то, как вы пишете описания инструментов.

Чеклист для продакшена

Перед выпуском Generative UI в продакшен:

  • Все генерируемые компоненты обёрнуты в error boundary
  • Скелетные состояния загрузки для каждого инструмента
  • Статический фолбэк при недоступности AI или ошибке
  • Строгая Zod-валидация всех параметров инструментов
  • Логирование вызовов инструментов (имя инструмента, параметры, задержка)
  • Мониторинг задержки (алерт при >2 с до первого компонента)
  • Отслеживание стоимости каждого AI-инференса
  • Аудит доступности всех генерируемых комбинаций компонентов
  • Тестирование адаптивности генерируемых макетов на мобильных устройствах
  • Ограничение частоты запросов на серверном экшене

О тестировании

Тестирование Generative UI требует иного подхода по сравнению с традиционным UI. Коротко:

  • Тестируйте компоненты изолированно стандартными юнит-тестами — это просто React-компоненты
  • Тестируйте Zod-схемы отдельно, чтобы убедиться, что они принимают корректные и отклоняют некорректные входные данные
  • Для интеграционных тестов с AI проверяйте структурные свойства (нужный инструмент вызван, параметры валидны), а не точное содержимое (температура 22°)
  • Мокайте AI в CI и запускайте реальные AI-интеграционные тесты ночью

Эта тема заслуживает отдельной статьи. Пока что паттерны валидации и обработки ошибок, которые делают тесты надёжными, разобраны в «Создание Generative UI с Vercel AI SDK».

Рассмотренные альтернативы

Паттерны выше предполагают Vercel AI SDK с React Server Components. Две альтернативы, о которых стоит знать до того, как закоммититься:

  • Tambo / каталог компонентов как сервис. Opensource-фреймворк для AI-генерируемого UI на React (github.com/tambo-ai/tambo, ~11k stars на 2026-05) выпускается быстрее (не нужно писать код реестра) и централизует качество описаний. Подходит, когда скорость до первого демо важнее долговременной удельной стоимости.
  • Декларативные JSON-протоколы вроде Thesys C1 (закрытый API) или A2UI v0.9 (открытая спецификация Google, ноябрь 2025) отвязывают модель от React; любой клиент (веб, мобильный, голосовой) может отрендерить один и тот же payload. Подходит, когда у вас неwebповые поверхности — ценой собственного рендерера.
  • Голый JSON + ручной диспетчер. Никакого SDK. Вы пишете switch по именам инструментов. Самый дешёвый вариант на малых объёмах, самый трудный в поддержке после пяти инструментов.

Ось выбора — устойчивость и портируемость vs время до выпуска. Для большинства React-only продуктов выигрывает путь через SDK из этой статьи; для мультиповерхностных или вендор-нейтральных продуктов оцените A2UI.

Дальнейшее чтение


Работаете над реализацией Generative UI в React? Получите экспертную консультацию по архитектуре, производительности и готовности к продакшену.

ПоделитьсяTwitterLinkedInEmail
reactgenerative-uipatternsimplementation
A

Alex

Generative UI Engineer & Consultant

Senior-инженер, специализирующийся на AI-интерфейсах и системах Generative UI. Помогаю продуктовым командам шипить быстрее с правильным GenUI-стеком.

Будьте в курсе Generative UI

Еженедельные статьи, обновления фреймворков и практические руководства — прямо в почту.

Мы уважаем вашу конфиденциальность. Отписка в любой момент.

Нужна помощь с реализацией прочитанного?

Записаться на бесплатную консультацию