e-laborat

/ Blog

AI-Chatbot-Frontend mit React bauen: Streaming, Markdown und UX-Patterns

e-laborat
Technische GuidesReactChatbotFrontendTypeScriptStreaming

Der Backend ist fertig, die LLM-API läuft — aber was macht den Unterschied zwischen einem funktionierenden und einem guten AI-Chatbot? Das Frontend. Benutzer erwarten Streaming (Token für Token), Markdown-Support, Code-Highlighting und flüssige Interaktion. Dieser Artikel zeigt euch, wie ihr ein Production-Ready React Chatbot Frontend baut, das professionell aussieht und sich professionell anfühlt. Mit TypeScript, React Hooks und modernem CSS.

Setup und Dependencies

Zuerst das Projekt initialisieren:

# Create React app with TypeScript npx create-react-app ai-chatbot --template typescript cd ai-chatbot


# Dependencies für unser Chatbot npm install axios react-markdown react-syntax-highlighter zustand npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p

Wir nutzen: - axios: HTTP-Requests - react-markdown: Markdown rendern - react-syntax-highlighter: Code-Highlighting - zustand: State Management (leichter als Redux) - tailwindcss: Styling

In `tailwind.config.js`:

module.exports = {   content: [     "./index.html",     "./src/**/*.{js,ts,jsx,tsx}",   ],   theme: {     extend: {},   },   plugins: [], }

State Management mit Zustand

Definiert euren Chat State mit Zustand (deutlich einfacher als Redux):

// src/store/chatStore.ts import { create } from 'zustand';


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


export interface ChatSession {   id: string;   title: string;   messages: Message[]; }


interface ChatStore {   sessions: ChatSession[];   currentSessionId: string | null;   loading: boolean;   error: string | null;      // Actions   createSession: () => void;   selectSession: (id: string) => void;   addMessage: (role: 'user' | 'assistant', content: string) => void;   setLoading: (loading: boolean) => void;   setError: (error: string | null) => void;   getCurrentSession: () => ChatSession | undefined; }


export const useChatStore = create<ChatStore>((set, get) => ({   sessions: [],   currentSessionId: null,   loading: false,   error: null,      createSession: () => set((state) => {     const newSession: ChatSession = {       id: Date.now().toString(),       title: 'New Chat',       messages: [],     };     return {       sessions: [newSession, ...state.sessions],       currentSessionId: newSession.id,     };   }),      selectSession: (id: string) => set({ currentSessionId: id }),      addMessage: (role: 'user' | 'assistant', content: string) => set((state) => {     if (!state.currentSessionId) return state;          return {       sessions: state.sessions.map((session) => {         if (session.id === state.currentSessionId) {           return {             ...session,             messages: [               ...session.messages,               {                 id: Date.now().toString(),                 role,                 content,                 timestamp: new Date(),               },             ],           };         }         return session;       }),     };   }),      setLoading: (loading: boolean) => set({ loading }),   setError: (error: string | null) => set({ error }),      getCurrentSession: () => {     const state = get();     return state.sessions.find((s) => s.id === state.currentSessionId);   }, }));

Streaming Hook: Server-Sent Events richtig nutzen

Das Herz des Chatbots: Ein Hook, der Streaming Responses von hinten verarbeitet:

// src/hooks/useStreamingChat.ts import { useState, useCallback } from 'react'; import { useChatStore } from '../store/chatStore';


interface UseStreamingChatOptions {   apiUrl?: string; }


export const useStreamingChat = (options: UseStreamingChatOptions = {}) => {   const apiUrl = options.apiUrl || process.env.REACT_APP_API_URL || 'http://localhost:8000';   const [isStreaming, setIsStreaming] = useState(false);   const { addMessage, setLoading, setError, currentSessionId } = useChatStore();


  const sendMessage = useCallback(     async (userMessage: string) => {       if (!userMessage.trim() || !currentSessionId) {         return;       }


      try {         // Add user message immediately         addMessage('user', userMessage);         setLoading(true);         setError(null);         setIsStreaming(true);


        // Make streaming request         const response = await fetch(`${apiUrl}/api/chat/stream/`, {           method: 'POST',           headers: {             'Content-Type': 'application/json',             'Authorization': `Bearer ${localStorage.getItem('authToken')}`,           },           body: JSON.stringify({             message: userMessage,             session_id: currentSessionId,           }),         });


        if (!response.ok) {           throw new Error(`API error: ${response.statusText}`);         }


        // Handle streaming response         const reader = response.body?.getReader();         if (!reader) throw new Error('No reader available');


        const decoder = new TextDecoder();         let assistantMessage = '';


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


          const text = decoder.decode(value);           const lines = text.split('\n');


          for (const line of lines) {             if (line.startsWith('data: ')) {               const data = JSON.parse(line.slice(6));                              if (data.chunk) {                 // Accumulate the message                 assistantMessage += data.chunk;                 // Update the last message in real-time                 useChatStore.setState((state) => {                   const session = state.getCurrentSession();                   if (!session) return state;                                      return {                     sessions: state.sessions.map((s) => {                       if (s.id === session.id) {                         const messages = [...s.messages];                         if (messages[messages.length - 1]?.role === 'assistant') {                           messages[messages.length - 1].content = assistantMessage;                         } else {                           messages.push({                             id: Date.now().toString(),                             role: 'assistant',                             content: assistantMessage,                             timestamp: new Date(),                           });                         }                         return { ...s, messages };                       }                       return s;                     }),                   };                 });               }


              if (data.done) {                 // Streaming complete                 setIsStreaming(false);               }                              if (data.error) {                 setError(data.error);                 setIsStreaming(false);               }             }           }         }       } catch (error) {         const errorMessage = error instanceof Error ? error.message : 'Unknown error';         setError(errorMessage);         setIsStreaming(false);       } finally {         setLoading(false);       }     },     [currentSessionId, addMessage, setLoading, setError, apiUrl]   );


  return { sendMessage, isStreaming }; };

Message Component mit Markdown und Code-Highlighting

Ein reusable Message Component, der Markdown und Code-Highlighting handelt:

// src/components/Message.tsx import React from 'react'; import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';


interface MessageProps {   role: 'user' | 'assistant';   content: string;   timestamp?: Date; }


export const Message: React.FC<MessageProps> = ({ role, content, timestamp }) => {   const isAssistant = role === 'assistant';


  return (     <div className={`mb-4 flex ${isAssistant ? 'justify-start' : 'justify-end'}`}>       <div         className={`max-w-xs lg:max-w-md px-4 py-3 rounded-lg ${           isAssistant             ? 'bg-gray-200 text-gray-900'             : 'bg-blue-600 text-white'         }`}       >         <ReactMarkdown           components={{             code({ node, inline, className, children, ...props }) {               const match = /language-(\w+)/.exec(className || '');               const language = match ? match[1] : 'text';


              if (inline) {                 return (                   <code className="bg-gray-900 text-green-400 px-1 rounded text-sm">                     {children}                   </code>                 );               }


              return (                 <SyntaxHighlighter                   style={oneDark}                   language={language}                   PreTag="div"                   {...props}                 >                   {String(children).replace(/\n$/, '')}                 </SyntaxHighlighter>               );             },             a: ({ node, ...props }) => (               <a {...props} className="underline hover:text-gray-600" />             ),             ul: ({ node, ...props }) => (               <ul {...props} className="list-disc list-inside" />             ),             ol: ({ node, ...props }) => (               <ol {...props} className="list-decimal list-inside" />             ),           }}         >           {content}         </ReactMarkdown>         {timestamp && (           <p className="text-xs mt-2 opacity-70">             {timestamp.toLocaleTimeString()}           </p>         )}       </div>     </div>   ); };

Chat Container mit Typing Indicator

Der Main Chat Container:

// src/components/ChatContainer.tsx import React, { useEffect, useRef } from 'react'; import { useChatStore } from '../store/chatStore'; import { useStreamingChat } from '../hooks/useStreamingChat'; import { Message } from './Message';


export const ChatContainer: React.FC = () => {   const { getCurrentSession, loading } = useChatStore();   const { sendMessage, isStreaming } = useStreamingChat();   const [input, setInput] = React.useState('');   const messagesEndRef = useRef<HTMLDivElement>(null);


  const session = getCurrentSession();   const messages = session?.messages || [];


  // Auto-scroll to bottom   useEffect(() => {     messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });   }, [messages]);


  const handleSubmit = (e: React.FormEvent) => {     e.preventDefault();     if (input.trim()) {       sendMessage(input);       setInput('');     }   };


  return (     <div className="flex flex-col h-full max-w-4xl mx-auto">       {/* Messages */}       <div className="flex-1 overflow-y-auto p-4 space-y-4">         {messages.length === 0 && !isStreaming && (           <div className="flex items-center justify-center h-full">             <p className="text-gray-400 text-center">               Starten Sie ein Gespräch, indem Sie eine Nachricht eingeben.             </p>           </div>         )}


        {messages.map((message) => (           <Message             key={message.id}             role={message.role}             content={message.content}             timestamp={message.timestamp}           />         ))}


        {/* Typing Indicator */}         {isStreaming && (           <div className="flex justify-start mb-4">             <div className="bg-gray-200 px-4 py-3 rounded-lg">               <div className="flex space-x-2">                 <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" />                 <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-100" />                 <div className="w-2 h-2 bg-gray-500 rounded-full animate-bounce delay-200" />               </div>             </div>           </div>         )}


        <div ref={messagesEndRef} />       </div>


      {/* Input */}       <form         className="border-t border-gray-200 p-4 bg-white"       >         <div className="flex gap-2">           <input             type="text"             value={input} => setInput(e.target.value)}             placeholder="Schreiben Sie eine Nachricht..."             disabled={loading || isStreaming}             className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"           />           <button             type="submit"             disabled={loading || isStreaming || !input.trim()}             className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"           >             {isStreaming ? 'Senden...' : 'Senden'}           </button>         </div>       </form>     </div>   ); };

Eine Sidebar zum Verwalten mehrerer Chat-Sessions:

// src/components/Sidebar.tsx import React from 'react'; import { useChatStore } from '../store/chatStore';


export const Sidebar: React.FC = () => {   const { sessions, currentSessionId, createSession, selectSession } = useChatStore();


  return (     <div className="w-64 bg-gray-100 border-r border-gray-200 flex flex-col">       {/* Header */}       <div className="p-4 border-b border-gray-200">         <button           className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"         >           + Neuer Chat         </button>       </div>


      {/* Sessions List */}       <div className="flex-1 overflow-y-auto">         {sessions.map((session) => (           <button             key={session.id} => selectSession(session.id)}             className={`w-full text-left px-4 py-3 border-b border-gray-200 hover:bg-gray-200 transition ${               session.id === currentSessionId ? 'bg-gray-300 font-bold' : ''             }`}           >             <div className="truncate">{session.title || 'Unnamed Chat'}</div>             <div className="text-sm text-gray-600 mt-1">               {session.messages.length} messages             </div>           </button>         ))}       </div>


      {/* Settings */}       <div className="p-4 border-t border-gray-200">         <button className="w-full px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg">           ⚙️ Einstellungen         </button>       </div>     </div>   ); };

Main App Component

Alles zusammenbinden:

// src/App.tsx import React, { useEffect } from 'react'; import { useChatStore } from './store/chatStore'; import { Sidebar } from './components/Sidebar'; import { ChatContainer } from './components/ChatContainer';


function App() {   const { createSession, sessions } = useChatStore();


  // Create initial session on load   useEffect(() => {     if (sessions.length === 0) {       createSession();     }   }, []);


  return (     <div className="flex h-screen bg-white">       <Sidebar />       <div className="flex-1 flex flex-col">         <div className="bg-white border-b border-gray-200 p-4">           <h1 className="text-2xl font-bold">AI Chatbot</h1>           <p className="text-gray-600">Powered by Claude AI</p>         </div>         <ChatContainer />       </div>     </div>   ); }


export default App;

Und in `index.css`:

@tailwind base; @tailwind components; @tailwind utilities;


@layer utilities {   .delay-100 {     animation-delay: 100ms;   }   .delay-200 {     animation-delay: 200ms;   } }

Error Handling und Toast Notifications

In Produktion braucht ihr auch Error Handling:

// src/components/Toast.tsx import React, { useState, useEffect } from 'react'; import { useChatStore } from '../store/chatStore';


export const Toast: React.FC = () => {   const { error } = useChatStore();   const [visible, setVisible] = useState(false);


  useEffect(() => {     if (error) {       setVisible(true);       const timer = setTimeout(() => setVisible(false), 5000);       return () => clearTimeout(timer);     }   }, [error]);


  if (!visible || !error) return null;


  return (     <div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg">       <p className="font-bold">Fehler</p>       <p className="text-sm">{error}</p>     </div>   ); };

Und in `App.tsx` einbauen:

function App() {   // ... rest of the code   return (     <div className="flex h-screen bg-white">       <Sidebar />       <div className="flex-1 flex flex-col">         {/* ... */}       </div>       <Toast /> {/* Add this */}     </div>   ); }

Performance und Accessibility

Ein paar wichtige Optimierungen:

1. Lazy Loading für lange Chat-Historien: Bei vielen Nachrichten nur die neuesten rendern

const VISIBLE_MESSAGES_LIMIT = 50; const visibleMessages = messages.slice(-VISIBLE_MESSAGES_LIMIT);

2. Memoization: Für Message Components um Re-renders zu vermeiden

export const Message = React.memo(MessageComponent);

3. ARIA Labels: Für Accessibility

<button   aria-label="Send message"   role="button"   aria-disabled={isStreaming} >

4. Keyboard Navigation: Enter zum Senden

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

KI-Beratung für Ihr Unternehmen

e-laborat hilft Mittelständlern bei der KI-Einführung — pragmatisch, praxisnah, mit Berliner Startup-Mentalität.

Erstgespräch vereinbaren →

Fazit

Ihr habt jetzt ein vollständiges, Production-Ready React Chatbot Frontend mit: - Streaming Responses - Markdown Support mit Code-Highlighting - Session Management - Error Handling - Responsive Design - Accessibility Features

Das ist nicht nur ein Frontend, das funktioniert — es ist eines, das Professional aussieht und sich Professional anfühlt. Benutzer werden die Streaming UX lieben, Entwickler werden die saubere Struktur mit Zustand lieben.

Nächste Schritte: Deployment (Vercel, Netlify), Progressive Web App Features, oder Dark Mode. Oder kontaktiert e-laborat für ein vollständiges Full-Stack Setup mit Django Backend, React Frontend und einer RAG Pipeline — alles production-ready.