You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

React 18 流式AI聊天消息更新的 stale state 问题及稳定实现方案咨询

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:

  1. Stale State in setMessages: You're relying on the closed-over messages array from the initial render of handleSend. When new renders happen (like when the user sends another message), this array is outdated, so your state updates overwrite with old data.
  2. 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.
  3. Incorrect Assistant Message Updates: Every chunk adds a full new entry to the messages array instead of updating a single, existing assistant message.
  4. Stale API Payload: You send the messages array (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

  1. Functional State Updates:

    • Instead of setMessages([...messages, ...]), we use setMessages(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.
  2. 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 fetch so the API request is cancelled too.
  3. 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.
  4. Ref for Latest State:

    • messagesRef syncs with the state on every render, so we always have the latest conversation history to send to the API (no stale payloads).
  5. Strict Mode Compatibility:

    • Functional updates and AbortController ensure state changes are predictable even when React double-invokes setup functions. Abort errors are ignored since they're expected during normal user interactions.

Optional Enhancements for Production

  • useReducer for Complex State: If you add features like editing messages or clearing history, switch to useReducer to 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 setTimeout to 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.

火山引擎 最新活动