ผมเคยเจอปัญหาแบบนี้ครับ: หลังจากตั้งโปรเจกต์ React ส่งขึ้น production ได้สองสัปดาห์ ทีมงานรายงานว่า AI chat ที่สร้างไว้ช้าเป็น snail ลูกค้าต้องรอเปิดหน้าเว็บขึ้นมาแล้วรอ 8-10 วินาที กว่าจะเห็นคำตอบเต็มๆ จนกว่า API จะส่ง response กลับมาทั้งหมด พอเปลี่ยนมาใช้ streaming output ด้วย HolySheep AI ผมลด latency ลงเหลือต่ำกว่า 50ms แถมค่าใช้จ่ายถูกลง 85% เมื่อเทียบกับ API เจ้าอื่น ในบทความนี้ผมจะสอนวิธีสร้าง React component ที่รองรับ streaming output อย่างมืออาชีพ พร้อมวิธีแก้ปัญหาที่พบบ่อยในการใช้งานจริง

ทำไมต้องใช้ Streaming Output?

Streaming output คือเทคนิคที่ AI API ส่งข้อมูลกลับมาเป็น "ก้อนเล็กๆ" ทีละส่วน ทำให้ UI แสดงผลได้ทันทีที่ได้รับข้อมูลแต่ละส่วน แทนที่จะรอจนได้คำตอบเต็มๆ ในการใช้ สมัครที่นี่ คุณจะได้รับเครดิตฟรีเมื่อลงทะเบียน พร้อม latency เฉลี่ยต่ำกว่า 50ms ทำให้ประสบการณ์ผู้ใช้ลื่นไหลมาก

ข้อดีหลักๆ ของ streaming มีดังนี้:

สร้าง Custom Hook สำหรับ Streaming

เราจะสร้าง React hook ที่จัดการ streaming logic ทั้งหมด รองรับ cancel, error handling และ auto-scroll อัตโนมัติ

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

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

interface UseStreamingChatOptions {
  apiKey: string;
  model?: string;
  baseUrl?: string;
}

interface UseStreamingChatReturn {
  messages: Message[];
  isLoading: boolean;
  error: string | null;
  sendMessage: (content: string) => Promise;
  cancel: () => void;
  clearMessages: () => void;
}

export function useStreamingChat({
  apiKey,
  model = 'gpt-4.1',
  baseUrl = 'https://api.holysheep.ai/v1'
}: UseStreamingChatOptions): UseStreamingChatReturn {
  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef<AbortController | null>(null);
  const currentAssistantIdRef = useRef<string | null>(null);

  const cancel = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      abortControllerRef.current = null;
      setIsLoading(false);
    }
  }, []);

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

  const sendMessage = useCallback(async (content: string) => {
    // ยกเลิก request ก่อนหน้าถ้ามี
    cancel();

    // สร้าง user message ใหม่
    const userMessage: Message = {
      id: user-${Date.now()},
      role: 'user',
      content,
      timestamp: new Date()
    };

    // สร้าง placeholder สำหรับ assistant response
    const assistantMessage: Message = {
      id: assistant-${Date.now()},
      role: 'assistant',
      content: '',
      timestamp: new Date()
    };

    currentAssistantIdRef.current = assistantMessage.id;

    // เพิ่ม messages เข้า state
    setMessages(prev => [...prev, userMessage, assistantMessage]);
    setIsLoading(true);
    setError(null);

    // สร้าง AbortController ใหม่
    abortControllerRef.current = new AbortController();

    try {
      const response = await fetch(${baseUrl}/chat/completions, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': Bearer ${apiKey}
        },
        body: JSON.stringify({
          model,
          messages: [
            ...messages.map(m => ({
              role: m.role,
              content: m.content
            })),
            { role: 'user', content }
          ],
          stream: true
        }),
        signal: abortControllerRef.current.signal
      });

      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}));
        throw new Error(errorData.error?.message || HTTP ${response.status}: ${response.statusText});
      }

      // อ่าน streaming response เป็น ReadableStream
      const reader = response.body?.getReader();
      if (!reader) {
        throw new Error('Response body is not readable');
      }

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

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

        buffer += decoder.decode(value, { stream: true });
        
        // Parse ทีละบรรทัด (SSE format)
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            
            if (data === '[DONE]') {
              setIsLoading(false);
              return;
            }

            try {
              const parsed = JSON.parse(data);
              const content = parsed.choices?.[0]?.delta?.content;
              
              if (content && currentAssistantIdRef.current) {
                setMessages(prev => 
                  prev.map(msg => 
                    msg.id === currentAssistantIdRef.current
                      ? { ...msg, content: msg.content + content }
                      : msg
                  )
                );
              }
            } catch (e) {
              // Ignore JSON parse errors for partial data
              console.warn('Parse error:', e);
            }
          }
        }
      }

      setIsLoading(false);
    } catch (err: any) {
      if (err.name === 'AbortError') {
        setError('Request cancelled');
      } else {
        const errorMessage = err.message || 'Unknown error occurred';
        setError(errorMessage);
        
        // ลบ assistant message ที่ล้มเหลวออก
        setMessages(prev => prev.filter(m => m.id !== currentAssistantIdRef.current));
      }
      setIsLoading(false);
    } finally {
      abortControllerRef.current = null;
      currentAssistantIdRef.current = null;
    }
  }, [apiKey, model, baseUrl, cancel, messages]);

  return {
    messages,
    isLoading,
    error,
    sendMessage,
    cancel,
    clearMessages
  };
}

สร้าง Chat Component UI

ต่อไปจะสร้าง UI component ที่สวยงาม รองรับ auto-scroll และแสดงสถานะ loading

// StreamingChat.tsx
import React, { useRef, useEffect, useState } from 'react';
import { useStreamingChat } from './useStreamingChat';

interface StreamingChatProps {
  apiKey: string;
  model?: string;
}

export function StreamingChat({ apiKey, model = 'gpt-4.1' }: StreamingChatProps) {
  const { messages, isLoading, error, sendMessage, cancel, clearMessages } = useStreamingChat({
    apiKey,
    model
  });

  const [input, setInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Auto-scroll เมื่อมีข้อความใหม่
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // Resize textarea อัตโนมัติ
  useEffect(() => {
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto';
      textareaRef.current.style.height = ${Math.min(textareaRef.current.scrollHeight, 150)}px;
    }
  }, [input]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading) return;

    const messageToSend = input.trim();
    setInput('');
    
    await sendMessage(messageToSend);
  };

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

  return (
    <div className="streaming-chat-container">
      {/* Header */}
      <div className="chat-header">
        <h3>AI Chat - {model}</h3>
        <button onClick={clearMessages} className="btn-clear">
          Clear
        </button>
      </div>

      {/* Messages Area */}
      <div className="chat-messages">
        {messages.length === 0 && (
          <div className="empty-state">
            <p>เริ่มสนทนากับ AI ได้เลย 👋</p>
            <p className="hint">ลองถามคำถามหรือขอให้ช่วยเขียนโค้ดดูสิ</p>
          </div>
        )}

        {messages.map((msg) => (
          <div key={msg.id} className={message message-${msg.role}}>
            <div className="message-avatar">
              {msg.role === 'user' ? '👤' : '🤖'}
            </div>
            <div className="message-content">
              <div className="message-text">
                {msg.content}
                {msg.role === 'assistant' && isLoading && msg.id === messages[messages.length - 1]?.id && (
                  <span className="cursor">▍</span>
                )}
              </div>
              <div className="message-time">
                {msg.timestamp.toLocaleTimeString('th-TH', { 
                  hour: '2-digit', 
                  minute: '2-digit' 
                })}
              </div>
            </div>
          </div>
        ))}

        {error && (
          <div className="message message-error">
            <div className="error-icon">⚠️</div>
            <div className="error-content">{error}</div>
          </div>
        )}

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

      {/* Input Area */}
      <form className="chat-input-area" onSubmit={handleSubmit}>
        <textarea
          ref={textareaRef}
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="พิมพ์ข้อความ... (Enter ส่ง, Shift+Enter ขึ้นบรรทัดใหม่)"
          disabled={isLoading}
          rows={1}
        />
        <div className="input-actions">
          {isLoading ? (
            <button type="button" onClick={cancel} className="btn-cancel">
              ⏹️ หยุด
            </button>
          ) : (
            <button type="submit" disabled={!input.trim()} className="btn-send">
              ➤ ส่ง
            </button>
          )}
        </div>
      </form>
    </div>
  );
}

CSS Styling

/* StreamingChat.css */
.streaming-chat-container {
  max-width: 700px;
  margin: 0 auto;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #ffffff;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.chat-header h3 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
}

.btn-clear {
  background: rgba(255, 255, 255, 0.2);
  border: none;
  color: white;
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
  transition: background 0.2s;
}

.btn-clear:hover {
  background: rgba(255, 255, 255, 0.3);
}

.chat-messages {
  height: 450px;
  overflow-y: auto;
  padding: 20px;
  background: #f9fafb;
}

.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: #6b7280;
}

.empty-state .hint {
  font-size: 14px;
  margin-top: 8px;
  color: #9ca3af;
}

.message {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
  animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

.message-user {
  flex-direction: row-reverse;
}

.message-avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  flex-shrink: 0;
}

.message-user .message-avatar {
  background: #3b82f6;
}

.message-assistant .message-avatar {
  background: #10b981;
}

.message-content {
  max-width: 80%;
}

.message-text {
  padding: 12px 16px;
  border-radius: 12px;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-user .message-text {
  background: #3b82f6;
  color: white;