React + AI Entegrasyonu: Streaming, Bellek ve Maliyet Yönetimi Tam Rehberi
React + AI Entegrasyonu: Streaming, Bellek ve Maliyet Yönetimi Tam Rehberi
React projenize AI özelliği eklemeye karar verdiniz. İlk denemede birkaç saatte çalışır hale getirdiniz. Kullanıcı mesaj yazıyor, AI cevap veriyor. Güzel görünüyor.
Sonra sorunlar başlıyor.
Sayfa yenilenince sohbet kayboluyor. Uzun cevaplarda kullanıcı ekranı boş bekliyor, cevap bir anda geliyor. Aktif kullanıcı sayısı arttıkça API faturası kontrolden çıkıyor. Bileşen unmount olunca konsola hata yağıyor.
Bu sorunların tamamı mimari kararlardan kaynaklanıyor. Ve hepsi çözülebilir.
Bu yazıda React'a AI entegre ederken karşılaştığım sorunları, çözümlerini ve production'da sürdürülebilir bir yapı kurmanın yolunu kod örnekleriyle anlatıyorum.
Neden Sadece fetch() Yetmez?
Çoğu geliştirici AI entegrasyonuna şöyle başlar: kullanıcı mesaj gönderir, fetch ile API'ye istek atarsın, cevabı alırsın, state'e yazarsın, ekranda gösterirsin. İşe yarıyor. Ama yalnızca başlangıç için.
Bu yaklaşımın üç temel sorunu var.
Birincisi, cevap gelene kadar kullanıcı boş bekliyor. GPT veya Gemini gibi modeller cevabı parça parça üretiyor. Siz tüm cevabı bekliyorsanız kullanıcı beş saniye boyunca donuk bir ekrana bakıyor. Streaming bu sorunu çözüyor.
İkincisi, her istek önceki mesajları taşımıyor. Modele "az önce ne söylediğimi hatırlıyor musun?" diye sorduğunuzda hatırlamıyor, çünkü siz önceki mesajları isteğe eklemediniiz. Bellek yönetimi bu sorunu çözüyor.
Üçüncüsü, her mesaj tam conversation history'yi API'ye gönderiyor. 50 mesajlık bir sohbet 50 kez büyüyen bir payload demek. Maliyet katlanarak artıyor. Token yönetimi bu sorunu çözüyor.
Üçünü birlikte çözmeden AI entegrasyonunuz production'a çıkmaya hazır değil.
Streaming: Cevabı Parça Parça Göstermek
Streaming'in çalışma mantığı şu: API cevabı üretirken her kelimeyi veya kelime grubunu anlık olarak gönderiyor. Siz bu akışı yakalayıp ekranda gösteriyorsunuz. Kullanıcı yazmaya başlandığını görüyor, beklemek yerine okumaya başlıyor.
React'ta streaming için ReadableStream API kullanıyoruz. Anthropic, Gemini ve OpenAI API'lerinin tamamı streaming destekliyor.
Temel streaming hook şöyle kurulur:
JavaScript
import { useState, useCallback, useRef } from "react";
export function useAIStream() {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef(null);
const sendMessage = useCallback(async (userMessage) => {
const newMessages = [
...messages,
{ role: "user", content: userMessage }
];
setMessages(newMessages);
setIsStreaming(true);
abortControllerRef.current = new AbortController();
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: newMessages }),
signal: abortControllerRef.current.signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantMessage = "";
setMessages(prev => [
...prev,
{ role: "assistant", content: "" }
]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
assistantMessage += chunk;
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1] = {
role: "assistant",
content: assistantMessage
};
return updated;
});
}
} catch (error) {
if (error.name !== "AbortError") {
console.error("Streaming hatası:", error);
}
} finally {
setIsStreaming(false);
}
}, [messages]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return { messages, isStreaming, sendMessage, stopStreaming };
}
Bu hook'ta dikkat edilmesi gereken birkaç nokta var.
AbortController kullanıyoruz. Kullanıcı "durdur" butonuna basınca veya bileşen unmount olunca isteği iptal edebiliyoruz. Bu olmadan bileşen unmount olduktan sonra state güncellenmeye devam eder ve React memory leak uyarısı verir.
Streaming başladığında önce boş bir assistant mesajı ekliyoruz, sonra her chunk geldiğinde bu mesajı güncelliyoruz. Böylece kullanıcı kelimelerin birer birer belirdiğini görüyor.
Server tarafında da streaming response dönmeniz gerekiyor. Next.js ile şöyle yapılır:
JavaScript
export async function POST(req) {
const { messages } = await req.json();
const stream = new ReadableStream({
async start(controller) {
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01"
},
body: JSON.stringify({
model: "claude-sonnet-4-6",
max_tokens: 1024,
stream: true,
messages
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const text = parsed.delta?.text || "";
if (text) {
controller.enqueue(new TextEncoder().encode(text));
}
} catch {}
}
}
}
controller.close();
}
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
}
Bellek Yönetimi: Context Neden Kaybolur?
AI modelleri doğası gereği stateless. Her isteği bağımsız değerlendiriyor. "Az önce ne konuştuk?" sorusunu yanıtlayabilmesi için önceki mesajları her istekte göndermeniz gerekiyor.
Bu basit görünüyor ama iki farklı katmanda bellek yönetimi yapmanız gerekiyor.
Birinci katman, React state'i. Kullanıcı sayfada olduğu sürece sohbet geçmişini tutuyorsunuz. Yukarıdaki hook bunu zaten yapıyor. messages array'i her mesajı birikiyor ve her yeni istekte tamamı API'ye gönderiliyor.
İkinci katman, kalıcı depolama. Kullanıcı sayfayı yenilediğinde veya uygulamayı kapatıp açtığında sohbet kaybolmamalı. Bunun için localStorage, sessionStorage veya bir veritabanı kullanabilirsiniz.
JavaScript
import { useState, useEffect, useCallback, useRef } from "react";
const STORAGE_KEY = "ai_chat_history";
export function useAIStreamWithMemory(sessionId) {
const storageKey = `${STORAGE_KEY}_${sessionId}`;
const [messages, setMessages] = useState(() => {
if (typeof window === "undefined") return [];
try {
const saved = localStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem(storageKey, JSON.stringify(messages));
}
}, [messages, storageKey]);
const clearHistory = useCallback(() => {
setMessages([]);
localStorage.removeItem(storageKey);
}, [storageKey]);
return { messages, setMessages, clearHistory };
}
sessionId kullanıyoruz çünkü aynı kullanıcının farklı sohbet oturumları olabilir. Her oturum için ayrı bir storage key tutmak sohbet geçmişlerinin birbirine karışmasını önlüyor.
KVKK açısından da önemli bir not: kullanıcıya bu verilerin nerede saklandığını söylemeniz gerekiyor. Eğer backend'de saklıyorsanız veri işleme politikanıza eklemeniz şart. NeonCore gibi desktop AI araçlarında biz bu sorunu dosyaları kullanıcının cihazında tutarak, sadece chat mesajlarını API'ye göndererek çözdük. Bu yaklaşım özellikle kurumsal kullanıcılar için güçlü bir KVKK argümanı oluyor.
Token Maliyeti: En Çok Gözden Kaçan Sorun
Her sohbet isteğinde tüm mesaj geçmişini API'ye gönderiyorsunuz. 10 mesajlık sohbette bu makul. 100 mesajlık sohbette her istek devasa bir payload taşıyor ve token maliyeti katlanıyor.
Bunu kontrol altına almanın üç yolu var.
Birinci yol, mesaj sayısını sınırlamak. API'ye her zaman son N mesajı gönderin, tüm geçmişi değil.
JavaScript
const MAX_CONTEXT_MESSAGES = 20;
function getContextMessages(allMessages) {
if (allMessages.length <= MAX_CONTEXT_MESSAGES) {
return allMessages;
}
const systemMessages = allMessages.filter(m => m.role === "system");
const recentMessages = allMessages
.filter(m => m.role !== "system")
.slice(-MAX_CONTEXT_MESSAGES);
return [...systemMessages, ...recentMessages];
}
System mesajlarını her zaman koruyoruz çünkü bunlar modelin davranışını belirliyor. Son 20 mesajı alıyoruz. 21. mesajdan itibaren en eski mesaj context'ten düşüyor.
İkinci yol, token tahmini. Ücretlendirme mesaj sayısına değil token sayısına göre yapılıyor. Bir karakteri yaklaşık 4 token olarak hesaplayabilirsiniz (Türkçe için bu oran biraz daha yüksek).
JavaScript
function estimateTokens(messages) {
const totalChars = messages.reduce((sum, msg) => {
return sum + msg.content.length;
}, 0);
return Math.ceil(totalChars / 3.5);
}
const TOKEN_LIMIT = 4000;
function getMessagesWithinTokenLimit(messages) {
let tokenCount = 0;
const result = [];
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = Math.ceil(messages[i].content.length / 3.5);
if (tokenCount + msgTokens > TOKEN_LIMIT) break;
result.unshift(messages[i]);
tokenCount += msgTokens;
}
return result;
}
Üçüncü yol, özetleme. Çok uzun sohbetlerde eski mesajları modele özetletip summary olarak saklayabilirsiniz. Bu daha karmaşık ama en temiz çözüm. Yapay zeka ajanlarının gerçek kullanım senaryolarında bu yaklaşım sık kullanılıyor.
Hata Yönetimi: Production'da Ne Olur?
Geliştirme ortamında her şey çalışıyor. Production'da farklı şeyler olabilir.
API rate limit aşılabilir. Kullanıcı interneti kesilip açılabilir. Model kısa bir süre hizmet veremeyebilir. Streaming sırasında bağlantı kopabilir.
Bunların hepsine karşı hazırlıklı olmak gerekiyor.
JavaScript
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
async function fetchWithRetry(url, options, retries = 0) {
try {
const response = await fetch(url, options);
if (response.status === 429) {
if (retries < MAX_RETRIES) {
const retryAfter = response.headers.get("retry-after") || RETRY_DELAY;
await new Promise(resolve =>
setTimeout(resolve, Number(retryAfter) * 1000)
);
return fetchWithRetry(url, options, retries + 1);
}
throw new Error("Rate limit aşıldı, lütfen biraz bekleyin.");
}
if (!response.ok) {
throw new Error(`API hatası: ${response.status}`);
}
return response;
} catch (error) {
if (error.name === "AbortError") throw error;
if (retries < MAX_RETRIES) {
await new Promise(resolve =>
setTimeout(resolve, RETRY_DELAY * (retries + 1))
);
return fetchWithRetry(url, options, retries + 1);
}
throw error;
}
}
Rate limit (429) hatası geldiğinde API'nin söylediği süre kadar bekleyip yeniden deniyoruz. Genel ağ hatalarında üstel bekleme ile yeniden deniyoruz. AbortError'da yeniden denemiyoruz çünkü kullanıcı isteği kendisi durdurdu.
Kullanıcıya da hata durumunu göstermeniz gerekiyor. Boş ekran veya donup kalan arayüz kullanıcıyı kaybettirir.
JavaScript
const [error, setError] = useState(null);
const sendMessage = useCallback(async (userMessage) => {
setError(null);
try {
// ... streaming kodu
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message || "Bir hata oluştu. Lütfen tekrar deneyin.");
setMessages(prev => prev.filter((_, i) => i !== prev.length - 1));
}
} finally {
setIsStreaming(false);
}
}, [messages]);
Hata olduğunda yarım kalan assistant mesajını siliyoruz ve hata mesajını gösteriyoruz. Kullanıcı aynı mesajı tekrar gönderebilir.
Bileşen Tasarımı: Temiz Bir Chat UI
Hook'ları kurduktan sonra bileşeni yazmak görece basit. Ama birkaç UX detayı var.
Yeni mesaj geldiğinde scroll en alta inmelı. Kullanıcı yukarı kaydırmışsa otomatik scroll yapmamalısınız. Streaming devam ederken input disabled olmalı ama kullanıcı durdurabilmeli.
JavaScript
import { useEffect, useRef, useState } from "react";
export function ChatInterface({ sessionId }) {
const { messages, isStreaming, sendMessage, stopStreaming } =
useAIStream(sessionId);
const [input, setInput] = useState("");
const messagesEndRef = useRef(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
if (shouldAutoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
shouldAutoScrollRef.current = isAtBottom;
};
const handleSubmit = async () => {
if (!input.trim() || isStreaming) return;
const message = input;
setInput("");
await sendMessage(message);
shouldAutoScrollRef.current = true;
};
return (
<div className="chat-container">
<div className="messages" onScroll={handleScroll}>
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
{msg.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="input-area">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSubmit()}
disabled={isStreaming}
placeholder="Mesajınızı yazın..."
/>
{isStreaming ? (
<button onClick={stopStreaming}>Durdur</button>
) : (
<button onClick={handleSubmit} disabled={!input.trim()}>
Gönder
</button>
)}
</div>
</div>
);
}
Kullanıcı yukarı kaydırdığında otomatik scroll durduğu için eski mesajları okuyabilir. En alta indiğinde tekrar otomatik scroll başlar.
System Prompt ve Model Davranışı
AI'ın nasıl davranacağını system prompt ile belirliyorsunuz. Bu, her istekte gönderdiğiniz gizli bir mesaj. Kullanıcı görmüyor ama model okuyor.
System prompt'u doğru yazmak entegrasyonun kalitesini doğrudan etkiliyor.
JavaScript
const SYSTEM_PROMPT = `Sen [ürün adı] için yardımcı bir asistansın.
Görevin:
- Kullanıcının sorularını net ve kısa yanıtla
- Türkçe konuş, teknik terimleri açıkla
- Bilmediğin şeyleri tahmin etme, "bilmiyorum" de
- Ürün dışı konulara girme
Kısıtlamalar:
- Kişisel bilgi sorma
- Promisler yapma, sadece ne yapabileceğini anlat`;
const messagesWithSystem = [
{ role: "system", content: SYSTEM_PROMPT },
...contextMessages
];
System prompt token tüketiyor, bunu aklınızda tutun. Her istekte gönderildiği için uzun tutmak maliyeti artırıyor. Kısa ve net tutun.
Production'a Çıkarken Kontrol Listesi
Streamingi test ettiniz, bellek çalışıyor, maliyet kontrol altında. Production'a geçmeden önce şunları kontrol edin.
API anahtarı hiçbir zaman frontend koduna girmiyor olmalı. Her zaman backend'de tutun, environment variable olarak. Bu en sık yapılan hatalardan biri.
Rate limiting uygulayın. Kullanıcı başına dakikada kaç istek atabileceğini sınırlayın. Hem maliyeti kontrol eder hem kötüye kullanımı önler.
Logging ekleyin. Hangi istekler başarılı oldu, hangileri hata verdi, ortalama yanıt süresi ne? Bu verileri görmeden darboğazı bulamazsınız.
Streaming timeout belirleyin. Model çok yavaş cevap verirse kullanıcıyı sonsuza kadar bekletmeyin.
JavaScript
const STREAMING_TIMEOUT = 30000;
const timeoutId = setTimeout(() => {
abortControllerRef.current?.abort();
setError("Yanıt çok uzun sürdü. Lütfen tekrar deneyin.");
}, STREAMING_TIMEOUT);
try {
await streamResponse();
} finally {
clearTimeout(timeoutId);
}
MCP ve Araç Entegrasyonu
Temel chat entegrasyonunun ötesine geçmek istiyorsanız, yani AI'ın dosya okuması, web araması yapması veya kod çalıştırması gibi yetenekler kazandırmak istiyorsanız araç (tool) entegrasyonuna bakmanız gerekiyor.
Bu konu başlı başına ayrı bir rehber gerektiriyor. VS Code 1.124 ile MCP Apps rehberinde bu konuyu daha geniş ele aldım. Claude Managed Agents yazısında ise zamanlanmış görevler ve güvenli araç yönetimi var.
Kısaca belirtmek gerekirse: araç entegrasyonunda modele ne yapabileceğini tanımlıyorsunuz, model kendi kararıyla araçları çağırıyor, siz de bu çağrıları yönetiyorsunuz. React tarafında bu bir async döngüye dönüşüyor.
Hangi API Seçilmeli?
Bu yazıda örnekleri Anthropic API üzerinden verdim ama mimari aynı. Gemini API, OpenAI API veya başka bir sağlayıcı kullanırsanız streaming formatı biraz farklı ama hook mantığı değişmiyor.
Türkiye'den geliştirici olarak Türkçe dil kalitesi en önemli kriter. Gemini 2.5 Flash ve Claude Sonnet 4.6 bu alanda öne çıkıyor. Fiyatlandırma ise ölçeğe göre değişiyor.
Ben NeonCore'u geliştirirken bu kararları yaşadım. Gemini'yi seçtim çünkü 1 milyon token context window ve fiyatlandırma dengesi benim ölçeğim için en uygunuydu. Ama bu karar her proje için farklı olabilir.
Sık Sorulan Sorular
Streaming çalışıyor ama çok hızlı geliyor, okunmuyor.
Bu bir sorun değil, özellik. Ama yavaşlatmak isterseniz her chunk'ta kısa bir timeout ekleyebilirsiniz. Dikkat: bu gereksiz gecikme yaratır ve genellikle önerilmez.
Her refresh'te sohbet sıfırlanıyor.
LocalStorage entegrasyonunu yukarıda gösterdim. Sunucu taraflı kayıt için bir veritabanı gerekiyor: kullanıcı ID'si, sohbet ID'si ve mesajlar. Next.js ile Firestore veya Supabase kombinasyonu hızlı kurulabilen bir çözüm.
Token maliyeti tahminim doğru mu?
Türkçe metin için karakter başına 3.5 token tahmini yaklaşık. Gerçek sayıyı öğrenmek için API'nin döndürdüğü usage alanını loglayın. Birkaç günlük veri sonrası gerçek oranı görebilirsiniz.
Aynı anda birden fazla kullanıcı kullanırsa ne olur?
Her kullanıcının kendi hook instance'ı var, birbirini etkilemiyor. Sunucu tarafında rate limiting uygularsanız toplam yük kontrol altında kalır.
Mobile'da streaming çalışıyor mu?
React Native'de fetch streaming desteği sınırlı. WebSocket veya Server-Sent Events kullanmak daha güvenilir bir yol. Web görünümünde ise sorunsuz çalışıyor.
---
React'a AI eklemek başlangıçta basit görünüyor. İlk demo birkaç saatte çalışıyor. Ama production kalitesinde bir entegrasyon streaming, bellek, maliyet ve hata yönetimini birlikte düşünmeyi gerektiriyor.
Bu yazıdaki hook'ları ve yapıyı başlangıç noktası olarak kullanın. Ürününüzün ihtiyaçlarına göre genişletin. Ölçeğiniz büyüdükçe optimizasyon gerektiren noktalar kendini gösterecek, o noktada log verileriniz size yol gösterecek.
Saygılarımla,
Aykan Kömürcü
Kurucu, NeonDijital
neondijital.com