Inicio / Inteligencia Artificial / AI-First Full Stack: Construye Apps con IA / Frontend AI con React: Chat UI y Componentes

Frontend AI con React: Chat UI y Componentes

Componentes de chat, renderizado de markdown, syntax highlighting, auto-scroll y estados de carga.

Intermedio Web
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Frontend AI con React: Chat UI y Componentes

Construir un frontend para aplicaciones AI-First requiere componentes especializados: chat con streaming, renderizado de Markdown y código, auto-scroll inteligente, estados de carga progresivos y UX adaptada al comportamiento no-determinista de los LLMs.


Arquitectura de componentes

<App>
├── <Sidebar>                    # Lista de conversaciones
│   ├── <ConversationList>
│   └── <NewChatButton>
├── <ChatView>                   # Vista principal
│   ├── <ChatHeader>             # Título + selector de modelo
│   ├── <MessageList>            # Mensajes con scroll
│   │   ├── <UserMessage>
│   │   ├── <AssistantMessage>   # Con Markdown + código
│   │   └── <ThinkingIndicator>
│   └── <ChatInput>              # Input + botones
│       ├── <TextArea>
│       ├── <FileUpload>
│       └── <SendButton>
└── <Providers>                  # Context providers
    ├── AuthProvider
    └── ChatProvider

Hook personalizado: useChat

// hooks/useChat.ts
import { useState, useCallback, useRef } from 'react';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  createdAt: Date;
}

interface UseChatOptions {
  conversationId?: string;
  model?: string;
  onError?: (error: Error) => void;
}

export function useChat(options: UseChatOptions = {}) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (content: string) => {
    if (!content.trim() || isLoading) return;

    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content,
      createdAt: new Date(),
    };

    const assistantMessage: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      createdAt: new Date(),
    };

    setMessages(prev => [...prev, userMessage, assistantMessage]);
    setIsLoading(true);
    setError(null);

    abortRef.current = new AbortController();

    try {
      const response = await fetch('/api/chat/stream', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${getToken()}`,
        },
        body: JSON.stringify({
          conversationId: options.conversationId,
          message: content,
          model: options.model ?? 'gpt-4o-mini',
        }),
        signal: abortRef.current.signal,
      });

      if (!response.ok) throw new Error('Error en la respuesta');

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const text = decoder.decode(value, { stream: true });
        const lines = text.split('\n').filter(l => l.startsWith('data: '));

        for (const line of lines) {
          try {
            const data = JSON.parse(line.slice(6));
            if (data.done) break;
            if (data.error) throw new Error(data.error);
            if (data.content) {
              setMessages(prev => {
                const updated = [...prev];
                const last = updated[updated.length - 1];
                updated[updated.length - 1] = {
                  ...last,
                  content: last.content + data.content,
                };
                return updated;
              });
            }
          } catch (e) {
            // Ignorar líneas mal formadas
          }
        }
      }
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        const error = err as Error;
        setError(error);
        options.onError?.(error);
      }
    } finally {
      setIsLoading(false);
    }
  }, [isLoading, options]);

  const stop = useCallback(() => {
    abortRef.current?.abort();
    setIsLoading(false);
  }, []);

  const reset = useCallback(() => {
    setMessages([]);
    setError(null);
  }, []);

  return { messages, isLoading, error, sendMessage, stop, reset };
}

Componente MessageList

// components/MessageList.tsx
import { useEffect, useRef } from 'react';
import { Message } from '../types';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import { ThinkingIndicator } from './ThinkingIndicator';

interface Props {
  messages: Message[];
  isLoading: boolean;
}

export function MessageList({ messages, isLoading }: Props) {
  const bottomRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  // Auto-scroll inteligente: solo si el usuario está cerca del fondo
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const isNearBottom =
      container.scrollHeight - container.scrollTop - container.clientHeight < 100;

    if (isNearBottom) {
      bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
    }
  }, [messages]);

  if (messages.length === 0) {
    return <EmptyState />;
  }

  return (
    <div ref={containerRef} className="flex-1 overflow-y-auto px-4 py-6">
      <div className="max-w-3xl mx-auto space-y-6">
        {messages.map(msg =>
          msg.role === 'user' ? (
            <UserMessage key={msg.id} message={msg} />
          ) : (
            <AssistantMessage key={msg.id} message={msg} />
          )
        )}

        {isLoading && messages[messages.length - 1]?.content === '' && (
          <ThinkingIndicator />
        )}

        <div ref={bottomRef} />
      </div>
    </div>
  );
}

function EmptyState() {
  return (
    <div className="flex-1 flex items-center justify-center">
      <div className="text-center space-y-4">
        <h2 className="text-2xl font-semibold text-gray-700">
          ¿En qué puedo ayudarte?
        </h2>
        <div className="grid grid-cols-2 gap-3 max-w-md">
          {[
            'Explica async/await en TypeScript',
            'Crea un hook de React para fetch',
            'Diseña un schema de base de datos',
            'Revisa mi código y sugiere mejoras',
          ].map(suggestion => (
            <button
              key={suggestion}
              className="p-3 text-sm text-left border rounded-lg hover:bg-gray-50 transition"
            >
              {suggestion}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

Renderizado de Markdown con Syntax Highlighting

// components/AssistantMessage.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useState } from 'react';

interface Props {
  message: Message;
}

export function AssistantMessage({ message }: Props) {
  return (
    <div className="flex gap-3">
      <div className="w-8 h-8 rounded-full bg-purple-500 flex items-center
                      justify-center text-white text-sm font-bold shrink-0">
        AI
      </div>
      <div className="prose prose-sm max-w-none">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            code({ className, children, ...props }) {
              const match = /language-(\w+)/.exec(className || '');
              const code = String(children).replace(/\n$/, '');

              if (match) {
                return (
                  <CodeBlock language={match[1]} code={code} />
                );
              }
              return (
                <code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm" {...props}>
                  {children}
                </code>
              );
            },
            // Tablas con estilo
            table({ children }) {
              return (
                <div className="overflow-x-auto">
                  <table className="min-w-full border-collapse border">
                    {children}
                  </table>
                </div>
              );
            },
          }}
        >
          {message.content}
        </ReactMarkdown>
      </div>
    </div>
  );
}

function CodeBlock({ language, code }: { language: string; code: string }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="relative group">
      <div className="flex items-center justify-between bg-gray-800
                      text-gray-200 px-4 py-2 text-xs rounded-t-lg">
        <span>{language}</span>
        <button
          onClick={handleCopy}
          className="opacity-0 group-hover:opacity-100 transition"
        >
          {copied ? '✓ Copiado' : 'Copiar'}
        </button>
      </div>
      <SyntaxHighlighter
        language={language}
        style={oneDark}
        customStyle={{ margin: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0 }}
      >
        {code}
      </SyntaxHighlighter>
    </div>
  );
}

ChatInput con TextArea auto-expandible

// components/ChatInput.tsx
import { useRef, useEffect, KeyboardEvent } from 'react';

interface Props {
  value: string;
  onChange: (value: string) => void;
  onSubmit: () => void;
  onStop: () => void;
  isLoading: boolean;
}

export function ChatInput({ value, onChange, onSubmit, onStop, isLoading }: Props) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Auto-resize
  useEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = 'auto';
      textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
    }
  }, [value]);

  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      onSubmit();
    }
  };

  return (
    <div className="border-t bg-white p-4">
      <div className="max-w-3xl mx-auto flex items-end gap-2">
        <textarea
          ref={textareaRef}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Escribe tu mensaje... (Shift+Enter para nueva línea)"
          rows={1}
          className="flex-1 resize-none border rounded-lg px-4 py-3 focus:outline-none
                     focus:ring-2 focus:ring-blue-500 max-h-[200px]"
          disabled={isLoading}
        />
        {isLoading ? (
          <button
            onClick={onStop}
            className="px-4 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600"
          >
            ⬛ Detener
          </button>
        ) : (
          <button
            onClick={onSubmit}
            disabled={!value.trim()}
            className="px-4 py-3 bg-blue-500 text-white rounded-lg
                       hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Enviar ↑
          </button>
        )}
      </div>
      <p className="text-xs text-gray-400 text-center mt-2">
        La IA puede cometer errores. Verifica la información importante.
      </p>
    </div>
  );
}

Selector de modelo

// components/ModelSelector.tsx
const MODELS = [
  { id: 'gpt-4o-mini', name: 'GPT-4o Mini', description: 'Rápido y económico' },
  { id: 'gpt-4o', name: 'GPT-4o', description: 'Más capaz' },
  { id: 'claude-sonnet', name: 'Claude Sonnet', description: 'Mejor para código' },
] as const;

interface Props {
  selected: string;
  onChange: (model: string) => void;
}

export function ModelSelector({ selected, onChange }: Props) {
  return (
    <select
      value={selected}
      onChange={(e) => onChange(e.target.value)}
      className="border rounded-lg px-3 py-1.5 text-sm bg-white"
    >
      {MODELS.map(model => (
        <option key={model.id} value={model.id}>
          {model.name} — {model.description}
        </option>
      ))}
    </select>
  );
}

Componente principal: ChatView

// components/ChatView.tsx
import { useState } from 'react';
import { useChat } from '../hooks/useChat';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { ModelSelector } from './ModelSelector';

export function ChatView({ conversationId }: { conversationId?: string }) {
  const [model, setModel] = useState('gpt-4o-mini');
  const [input, setInput] = useState('');

  const { messages, isLoading, error, sendMessage, stop } = useChat({
    conversationId,
    model,
    onError: (err) => console.error('Chat error:', err),
  });

  const handleSubmit = () => {
    if (input.trim()) {
      sendMessage(input);
      setInput('');
    }
  };

  return (
    <div className="flex flex-col h-screen">
      {/* Header */}
      <div className="border-b px-4 py-3 flex items-center justify-between">
        <h1 className="font-semibold">Chat AI</h1>
        <ModelSelector selected={model} onChange={setModel} />
      </div>

      {/* Messages */}
      <MessageList messages={messages} isLoading={isLoading} />

      {/* Error */}
      {error && (
        <div className="px-4 py-2 bg-red-50 text-red-600 text-sm text-center">
          Error: {error.message}
        </div>
      )}

      {/* Input */}
      <ChatInput
        value={input}
        onChange={setInput}
        onSubmit={handleSubmit}
        onStop={stop}
        isLoading={isLoading}
      />
    </div>
  );
}
🔒

Ejercicio práctico disponible

Lógica de chat UI: mensajes y auto-scroll

Desbloquear ejercicios
// Lógica de chat UI: mensajes y auto-scroll
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes