You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何useState钩子会被调用两次?附复现测试用例

React useState更新回调重复执行问题

问题现象

提交请求后,明明只调用了一次updateCurrentConversation,但setConversations的回调函数却执行了两次,控制台输出两条相同的更新日志,最终导致对话中出现重复的响应内容。

复现场景

  • 用户输入查询内容并点击发送按钮
  • 创建新的对话对象并添加到对话列表
  • 调用invokeServerAPI处理请求,传入响应回调函数实时展示结果
  • 回调触发后更新当前对话,但setConversations的更新逻辑被执行两次

控制台输出

page.tsx:25 Text response received: Response for query:sample query 1
page.tsx:31 Invoke updateCurrentConversation with response:  Response for query:sample query 1
page.tsx:31 Invoke updateCurrentConversation with response:  Response for query:sample query 1

问题代码

"use client"

import React, { useState, useRef } from 'react';

export default function MyApp() {
  const [input, setInput] = useState('');
  const [conversations, setConversations] = useState([]);
  const messagesEndRef = useRef(null);

  const handleSubmit = async (event) => {
    event.preventDefault();

    const startTime = new Date()
    const newConversation = { query: input, startTime, responses: [] }
    setConversations((prevConversations) => [...prevConversations, newConversation]);

    try {
      await invokeServerAPI(input, textHandler)
    }
    finally {
      setInput('');
    }

    function textHandler(text) {
      console.log("Text response received:", text)
      updateCurrentConversation(text)
    }

    function updateCurrentConversation(data) {
      setConversations((prevConversations) => {
        console.log('Invoke updateCurrentConversation with response: ', data)
        const index = prevConversations.findIndex(conversation => conversation.startTime === startTime);
        const _newConversation = { ...prevConversations[index] };
        _newConversation.responses.push({ data })
        return [
          ...prevConversations.slice(0, index),
          _newConversation,
          ...prevConversations.slice(index + 1)
        ];
      });
    }
  };

  return (
    <div className="flex flex-col h-screen">
      <div className="overflow-y-scroll flex-grow p-2">
        {conversations.map((conversation, index) => (
          <div key={index} className="mb-5">
            <p>{conversation.startTime.toLocaleString()} <span className="font-bold">{conversation.query})</span>:</p>
            {conversation.responses
              .map((response, respIndex) => {
                return (
                  <div key={index * 1000 + respIndex} className="mb-2">
                      <pre className="mt-2 bg-gray-100 p-2 rounded border border-gray-300 whitespace-pre-wrap">{response.data}</pre>
                  </div>
                );
              })
            }
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>
      <div className="p-2 bg-gray-800 text-white">
        <form onSubmit={handleSubmit}>
          <div className="flex justify-start space-x-4">
            <div className="flex flex-col" style={{ marginRight: '1rem', marginLeft: '0' }}>
              <label className="text-black" style={{ color: 'black', display: 'block', width: 'auto' }}>Query:</label>
              <input
                type="text"
                value={input}
                onChange={(e) => setInput(e.target.value)}
                className="p-2 bg-gray-300 text-black rounded-md"
                style={{ fontSize: '18px', color: 'black', height: '40px', width: '500px' }}
                placeholder="Enter your query here"
              />
            </div>
            <div className="flex flex-col items-start">
              <label style={{ color: 'black', display: 'block', width: 'auto' }}>&nbsp;</label>
              <button type="submit" className="bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
                style={{ paddingLeft: '1rem', paddingRight: '1rem', height: '40px', marginLeft: '0' }}>Send</button>
            </div>
          </div>
        </form>
      </div>
    </div >
  );
}

export function invokeServerAPI(theQuery, theHandler) {
  return new Promise((resolve, reject) => {
    // 模拟单次响应
    theHandler('Response for query:' + theQuery)
    resolve(null);
  })
}

问题原因

1. React严格模式(Strict Mode)

这是最核心的原因。在开发环境下,React严格模式会重复调用状态更新函数、副作用函数,目的是检测代码中是否存在非幂等的副作用逻辑。你的代码标注了"use client",在Next.js等现代框架中,客户端组件默认启用严格模式,因此setConversations的回调会被执行两次。

2. 对话标识不可靠

你用startTime作为查找对话的唯一标识,但Date的精度有限(部分环境下仅到毫秒),如果用户快速连续提交请求,可能会出现相同的startTime,导致错误更新其他对话。

解决方案

方案1:开发环境忽略该现象

严格模式的重复执行仅存在于开发环境,生产环境会自动禁用该行为。如果只是开发时的日志干扰,无需额外处理。

方案2:关闭严格模式

若想彻底消除开发环境的重复执行,可以在框架配置中关闭严格模式。以Next.js为例,修改next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: false,
}
module.exports = nextConfig

方案3:确保状态更新逻辑幂等

即使更新函数被执行多次,也不会产生错误结果。比如在添加响应前检查是否已存在:

function updateCurrentConversation(data) {
  setConversations((prevConversations) => {
    const index = prevConversations.findIndex(conversation => conversation.id === conversationId);
    const targetConversation = prevConversations[index];
    // 检查是否已添加过该响应
    const hasResponse = targetConversation.responses.some(r => r.data === data);
    if (hasResponse) return prevConversations;
    
    const _newConversation = { ...targetConversation };
    _newConversation.responses.push({ data });
    return [
      ...prevConversations.slice(0, index),
      _newConversation,
      ...prevConversations.slice(index + 1)
    ];
  });
}

方案4:使用唯一ID标识对话

替换startTime为唯一ID,避免标识冲突:

// 创建对话时生成唯一ID
const newConversation = { 
  id: crypto.randomUUID(),
  query: input, 
  startTime: new Date(), 
  responses: [] 
};
// 保存对话ID到闭包中
const conversationId = newConversation.id;

// 更新时用ID查找
const index = prevConversations.findIndex(conversation => conversation.id === conversationId);

内容的提问来源于stack exchange,提问作者Ya.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 15:54:54