从 useState 迁移到 TanStack Query

将组件内的服务端状态从 useState + useEffect + 手动缓存,增量迁移到 TanStack Query 的四步策略。

#type / howto #status / growing #tech / dev / frontend #resource / react

[!info] related notes

从 useState 迁移到 TanStack Query

目标

把组件里用 useState 维护的服务端数据(列表、详情、请求状态)迁移到 TanStack Query,消除手动缓存逻辑和竞态问题。

前置条件

迁移策略:四步增量法

不要一次性全量迁移。按风险从低到高分四步走。

第一步:收敛 ID 来源

把所有”当前资源是谁”的判断收敛到 URL。

// 迁移前:两套身份源
const { id } = useParams();
const [currentConversation, setCurrentConversation] = useState(null);

// 迁移后:只看 URL
const routeConversationId = id && id !== 'new' ? id : null;
const chatSessionKey = routeConversationId
  ? `conversation:${routeConversationId}`
  : 'new';

同时把侧边栏高亮等依赖”当前 ID”的地方,从 currentConversation?.id 改成 routeConversationId

这一步不引入 useQuery,只收敛身份源。

第二步:迁移列表查询

先把列表迁到 query:

const conversationsQuery = useQuery({
  queryKey: ['consultation', 'conversations'],
  queryFn: () => consultationApi.listConversations({ limit: 50 }),
});

const conversations = conversationsQuery.data?.conversations ?? [];

删除相关的 useState:

- const [conversations, setConversations] = useState<Conversation[]>([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);

把删除、重命名、置顶等操作改成 mutation + setQueryData 更新列表缓存。

这一步解决列表和详情的同步问题。

第三步:迁移详情查询

把当前资源详情迁到 query:

const conversationQuery = useQuery({
  queryKey: consultationKeys.conversation(routeConversationId),
  queryFn: () => consultationApi.getConversation(routeConversationId!),
  enabled: !!routeConversationId,
});

const currentConversation = conversationQuery.data?.conversation ?? null;
const messages = conversationQuery.data?.messages ?? [];

删除相关的 useState:

- const [currentConversation, setCurrentConversation] = useState(null);
- const [messages, setMessages] = useState([]);

删除初始化 useEffect:

- useEffect(() => {
-   if (id && id !== 'new') {
-     loadConversation(id);
-   }
- }, [id, currentConversation]);

把删除操作改成 mutation + navigate

const deleteConversationMutation = useMutation({
  mutationFn: consultationApi.deleteConversation,
  onSuccess: (_data, deletedId) => {
    // 更新列表缓存
    queryClient.setQueryData(consultationKeys.conversations(), (old) => {
      if (!old) return old;
      return {
        ...old,
        conversations: old.conversations.filter((c) => c.id !== deletedId),
      };
    });
    // 删除详情缓存
    queryClient.removeQueries({
      queryKey: consultationKeys.conversation(deletedId),
    });
    // 如果删的是当前会话,导航回列表
    if (routeConversationId === deletedId) {
      navigate('/consultation', { replace: true });
    }
  },
});

这一步解决删除/切换/新建的核心竞态。

第四步:迁移领域数据和 mutation

最后把业务领域数据迁到 query / mutation:

const consultationQuery = useQuery({
  queryKey: consultationKeys.session(routeConversationId),
  queryFn: () => consultationApi.getConsultation(routeConversationId!),
  enabled: !!routeConversationId,
  staleTime: 60_000,
});

const extractedInfo = consultationQuery.data?.extracted_info ?? [];
const phase = consultationQuery.data?.phase ?? 'collecting';
const diagnoses = extractDiagnoses(consultationQuery.data?.diagnosis ?? null);
const treatmentPlan = extractTreatmentPlan(consultationQuery.data?.treatment_plan ?? null);

const analyzeDiagnosisMutation = useMutation({
  mutationFn: () => consultationApi.analyzeDiagnosis(routeConversationId!),
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: consultationKeys.session(routeConversationId),
    });
  },
});

删除相关的 useState:

- const [extractedInfo, setExtractedInfo] = useState([]);
- const [phase, setPhase] = useState('collecting');
- const [diagnoses, setDiagnoses] = useState([]);
- const [treatmentPlan, setTreatmentPlan] = useState(null);
- const [isAnalyzingDiagnosis, setIsAnalyzingDiagnosis] = useState(false);

这一步和 SSE 流式更新有关,建议单独一轮做。

验证方式

每一步完成后检查:

  1. 删除全部会话 → 侧边栏变空,URL 回到 /consultation,不出现旧 ID 重载
  2. 删除单个会话 → 如果删的是当前会话,自动切到空状态
  3. 新建会话 → URL 变新 ID,详情自动加载
  4. 切换会话 → URL 变 ID,详情自动切换
  5. 页面刷新 → 数据从后端重新加载,状态正确

常见问题

迁移后 loading 闪烁怎么办

不要整个页面大 loading。更好的方式:

const isConversationLoading = !!routeConversationId && conversationQuery.isPending;

侧边栏可以 loading,右侧详情可以 loading,聊天主面板不要轻易整页卸载。用局部 skeleton 或保留当前 active turn。

onConversationCreated 怎么处理

// ✅ 简洁方案:navigate + invalidate
onConversationCreated={async (newId) => {
  queryClient.invalidateQueries({
    queryKey: consultationKeys.conversations(),
  });
  navigate(`/consultation/${newId}`, { replace: true });
}}

// ✅ 如果创建接口已经返回完整数据,可以 prefill
onConversationCreated={async (newId, data) => {
  queryClient.setQueryData(consultationKeys.conversation(newId), data);
  navigate(`/consultation/${newId}`, { replace: true });
}}

// ❌ 不要为了 prefill 额外 getConversation
创建于 2026/7/2 更新于 2026/7/15