从 useState 迁移到 TanStack Query
将组件内的服务端状态从 useState + useEffect + 手动缓存,增量迁移到 TanStack Query 的四步策略。
#type / howto
#status / growing
#tech / dev / frontend
#resource / react
[!info] related notes
- 所属 MOC: TanStack Query 知识地图
- 前置: TanStack Query 服务端状态
- 前置: 组件内服务端状态与客户端状态的区分
- 参考: URL 作为唯一身份源
- 参考: 派生状态模式
- 参考: TanStack Query 缓存失效模式
- 参考: queryKey Factory 模式
从 useState 迁移到 TanStack Query
目标
把组件里用 useState 维护的服务端数据(列表、详情、请求状态)迁移到 TanStack Query,消除手动缓存逻辑和竞态问题。
前置条件
- 已安装
@tanstack/react-query - 已配置
QueryClientProvider - 已理解 服务端状态与客户端状态的区分
迁移策略:四步增量法
不要一次性全量迁移。按风险从低到高分四步走。
第一步:收敛 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 流式更新有关,建议单独一轮做。
验证方式
每一步完成后检查:
- 删除全部会话 → 侧边栏变空,URL 回到
/consultation,不出现旧 ID 重载 - 删除单个会话 → 如果删的是当前会话,自动切到空状态
- 新建会话 → URL 变新 ID,详情自动加载
- 切换会话 → URL 变 ID,详情自动切换
- 页面刷新 → 数据从后端重新加载,状态正确
常见问题
迁移后 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