React 18 流式AI聊天消息更新的 stale state 问题及稳定实现方案咨询
Let's break down exactly what's broken in your current code and how to fix it for stable streaming in React 18—even under strict mode.
Key Issues in Your Current Code
Your gut feeling about stale closures is spot-on! Here are the specific bugs:
- Stale State in
setMessages: You're relying on the closed-overmessagesarray from the initial render ofhandleSend. When new renders happen (like when the user sends another message), this array is outdated, so your state updates overwrite with old data. - Uncancelled Streams: No mechanism to abort previous streams when a new message is sent, leading to race conditions where multiple streams update the state at the same time.
- Incorrect Assistant Message Updates: Every chunk adds a full new entry to the messages array instead of updating a single, existing assistant message.
- Stale API Payload: You send the
messagesarray (before adding the user's new message) to your backend, so the API doesn't get the full conversation history.
Idiomatic Fix for Stable Streaming
Here's the clean, React 18-compliant solution that addresses all these issues. We'll use functional state updates, AbortController for race condition prevention, and proper tracking of the active assistant message.
Corrected Code Implementation
import { useState, useRef, useEffect } from "react"; type Message = { id: string; role: "user" | "assistant"; content: string; }; export default function Chat() { const [messages, setMessages] = useState<Message[]>([]); const [loading, setLoading] = useState(false); // Track active stream controller to cancel previous requests const abortControllerRef = useRef<AbortController | null>(null); // Ref to hold latest messages state for API calls (avoids stale closures) const messagesRef = useRef<Message[]>([]); // Sync ref with state to always have latest messages useEffect(() => { messagesRef.current = messages; }, [messages]); // Cancel stream on component unmount useEffect(() => { return () => abortControllerRef.current?.abort(); }, []); const handleSend = async (userInput: string) => { const trimmedInput = userInput.trim(); if (!trimmedInput) return; // Cancel any ongoing stream to prevent race conditions abortControllerRef.current?.abort(); const newController = new AbortController(); abortControllerRef.current = newController; // Create user and empty assistant messages const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmedInput, }; const assistantMsg: Message = { id: crypto.randomUUID(), role: "assistant", content: "", }; try { setLoading(true); // Add user + empty assistant message to state (functional update for latest state) setMessages((prev) => [...prev, userMsg, assistantMsg]); // Prepare full conversation history for API (uses ref to get latest state) const messagesToSend = [...messagesRef.current, userMsg]; const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages: messagesToSend, input: trimmedInput }), headers: { "Content-Type": "application/json" }, signal: newController.signal, }); if (!res.ok) throw new Error(`API Error: ${res.status}`); const reader = res.body?.getReader(); if (!reader) throw new Error("No response body to stream"); const decoder = new TextDecoder(); // Process stream chunks while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // Update only the active assistant message (functional update for latest state) setMessages((prev) => prev.map((msg) => msg.id === assistantMsg.id ? { ...msg, content: msg.content + chunk } : msg ) ); } } catch (err) { // Ignore expected abort errors when user sends a new message if (!(err instanceof Error) || err.name !== "AbortError") { console.error("Stream failed:", err); // Optional: Add error message to state setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), role: "assistant", content: "Sorry, something went wrong." }, ]); } } finally { // Only update loading state if this is the active stream (prevents race conditions) if (abortControllerRef.current === newController) { setLoading(false); abortControllerRef.current = null; } } }; return ( <div className="chat-container"> <div className="messages"> {messages.map((msg) => ( <div key={msg.id} className={`message ${msg.role}`}> <strong>{msg.role === "user" ? "You:" : "Assistant:"}</strong> {msg.content} </div> ))} </div> {loading && <div className="loading">Thinking...</div>} <form onSubmit={(e) => { e.preventDefault(); const input = e.target.elements.namedItem("userInput") as HTMLInputElement; handleSend(input.value); input.value = ""; }} > <input type="text" name="userInput" disabled={loading} placeholder="Type a message..." /> <button type="submit" disabled={loading}>Send</button> </form> </div> ); }
Critical Improvements Explained
Functional State Updates:
- Instead of
setMessages([...messages, ...]), we usesetMessages(prev => ...)to always get the latest state. This eliminates stale closures entirely. - For assistant message updates, we map over the previous state and only modify the specific message by ID.
- Instead of
AbortController for Race Conditions:
- A ref tracks the active stream controller. When the user sends a new message, we abort the previous stream immediately.
- We pass the controller's signal to
fetchso the API request is cancelled too.
Proper Assistant Message Tracking:
- We add an empty assistant message to the state before starting the stream, then update its content incrementally. This avoids adding duplicate entries for each chunk.
Ref for Latest State:
messagesRefsyncs with the state on every render, so we always have the latest conversation history to send to the API (no stale payloads).
Strict Mode Compatibility:
- Functional updates and
AbortControllerensure state changes are predictable even when React double-invokes setup functions. Abort errors are ignored since they're expected during normal user interactions.
- Functional updates and
Optional Enhancements for Production
useReducer for Complex State: If you add features like editing messages or clearing history, switch to
useReducerto keep state transitions explicit. Here's a quick reducer example:type Action = | { type: "ADD_MESSAGE"; message: Message } | { type: "UPDATE_MESSAGE"; id: string; content: string } | { type: "SET_LOADING"; loading: boolean }; function chatReducer(state: { messages: Message[]; loading: boolean }, action: Action) { switch (action.type) { case "ADD_MESSAGE": return { ...state, messages: [...state.messages, action.message] }; case "UPDATE_MESSAGE": return { ...state, messages: state.messages.map(m => m.id === action.id ? {...m, content: action.content} : m ), }; case "SET_LOADING": return { ...state, loading: action.loading }; default: return state; } } // Usage in component: const [state, dispatch] = useReducer(chatReducer, { messages: [], loading: false });Batch Updates for Performance: If you're getting extremely frequent small chunks, batch updates using a ref and
setTimeoutto reduce re-renders:const assistantContentRef = useRef(""); let batchTimeout: NodeJS.Timeout; // Inside stream loop: assistantContentRef.current += chunk; clearTimeout(batchTimeout); batchTimeout = setTimeout(() => { setMessages(prev => prev.map(m => m.id === assistantMsg.id ? {...m, content: assistantContentRef.current} : m )); }, 50); // Update every 50ms // Don't forget to clear timeout on abort/stream end!
This implementation will be stable even with rapid user inputs, strict mode enabled, and concurrent rendering in React 18.




