
去年我接手一个项目,第一次Code Review时,我看到这样的代码:一个简单的数据列表组件,却有5个useState、3个useEffect嵌套依赖,还附赠一个isMounted标志位来防止内存泄漏。我问:为什么要这样写?回答是:「网上教程都是这样写的」。
这就是React生态中最大的假象——我们集体性地误解了useEffect的用途。
一个容易被忽视的事实是:React中的状态从来不是平等的。它们属于两个完全不同的世界。
客户端状态(Client State):你100%拥有和控制它。一个modal的打开关闭、input框的输入内容、主题切换(深色/浅色)——这些东西全程在浏览器里,你说了算。
const [isDarkMode, setIsDarkMode] = useState(false);
const [inputValue, setInputValue] = useState('');
这些用useState是完全合理的。
服务端状态(Server State):你其实不拥有它。你手里拿的只是一份快照,一份远程服务器上数据的复印件。这份复印件随时可能过期。它可以在你不知道的时候被其他用户改掉,可能需要很长时间才能从网络上获取到,甚至可能永远获取不到。
// ❌ 错误的思维方式
const [products, setProducts] = useState([]);
// 你在欺骗应用:这里放的是真实的数据
// 但实际上这只是上次从API获取到的数据快照
大多数React开发者会用useState来存放API数据,这就是问题的根源。这个选择让你向应用程序说谎了。你本应该说「这是我们上次询问服务器时得到的版本」,却说成了「这是真实数据」。

这个根本认知错误衍生出一份无穷的TODO清单,所有这些问题都得手工处理:
isLoading、isError、isSuccess等状态来追踪请求的各个阶段// 真实项目的"标准做法" —— 一个简单列表却需要这样
function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true; // 防止卸载后setState警告
const fetchData = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/products');
if (!res.ok) thrownewError('请求失败');
const data = await res.json();
if (isMounted) setProducts(data);
} catch (err) {
if (isMounted) setError(err);
} finally {
if (isMounted) setIsLoading(false);
}
};
fetchData();
return() => { isMounted = false; };
}, []);
if (isLoading) return<div>加载中...</div>;
if (error) return<div>出错了:{error.message}</div>;
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
注意那个isMounted标志位——这其实是在用一个"黑科技"来解决React自身设计上的不匹配问题。从某种角度说,这验证了我们的方法从一开始就不对劲。
问题不在useEffect本身,而在于我们让一个通用的「副作用管理工具」去承担「专业的服务端状态管理」的工作。
这就像用螺丝刀去钉钉子——虽然可能能钉进去,但肯定会出事。
React的useEffect设计初衷是处理:
但它完全不适合处理:
这些任务的复杂性远远超过通用副作用管理的范畴。
一个成熟的服务端状态管理方案应该:
这些功能完全超出了useState+useEffect的能力范围。
TanStack Query(前身是React Query)的核心创新在于:它不是「另一个全局状态管理工具」,而是专门为服务端状态设计的同步引擎。
它的哲学很明确:
import { useState, useEffect } from'react';
function FilteredProducts({ categoryId }) {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let mounted = true;
const fetch = async () => {
try {
setIsLoading(true);
setError(null);
const res = await fetch(`/api/products?category=${categoryId}`);
if (!res.ok) thrownewError('请求失败');
const data = await res.json();
if (mounted) setProducts(data);
} catch (err) {
if (mounted) setError(err.message);
} finally {
if (mounted) setIsLoading(false);
}
};
fetch();
return() => { mounted = false; };
}, [categoryId]);
// 其他问题:
// 1. categoryId变化时,旧请求可能晚回来导致显示错误数据
// 2. 切换分类后数据会闪现加载中,即使新数据已经被请求过
// 3. 如果用户离开Tab又回来,数据不会自动刷新
// 4. 需要手工处理AbortController才能真正取消旧请求
if (isLoading) return<div>加载中...</div>;
if (error) return<div>错误:{error}</div>;
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}
这段代码看起来"标准",但暗藏至少4个BUG。
import { useQuery } from'@tanstack/react-query';
asyncfunction fetchProductsByCategory(categoryId) {
const res = await fetch(`/api/products?category=${categoryId}`);
if (!res.ok) thrownewError('请求失败');
return res.json();
}
function FilteredProducts({ categoryId }) {
const { data: products, isLoading, error } = useQuery({
queryKey: ['products', categoryId], // 👈 关键!带上依赖参数
queryFn: () => fetchProductsByCategory(categoryId),
staleTime: 5 * 60 * 1000, // 5分钟内数据视为新鲜
});
if (isLoading) return<div>加载中...</div>;
if (error) return<div>错误:{error.message}</div>;
return (
<div>
{products?.map(p => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}
完全相同的功能,代码从30多行下降到15行。更关键的是:
这不是少了几行代码,而是整个思维方式的升级。

只需在应用根部做一次配置:
import { QueryClient, QueryClientProvider } from'@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5分钟
gcTime: 10 * 60 * 1000, // 10分钟后从内存中清理
retry: 1, // 失败自动重试1次
refetchOnWindowFocus: true, // Tab激活时检查刷新
refetchOnReconnect: true, // 网络恢复时刷新
},
},
});
exportdefaultfunction App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
一旦配置好,所有使用useQuery的组件都自动获得这些能力。这就是配置驱动而非代码驱动的威力。
真正的考验来自于修改数据。这里useMutation展现出了威力。
我们希望的体验是:
import { useMutation, useQuery, useQueryClient } from'@tanstack/react-query';
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
return res.json();
},
});
}
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
}).then(r => r.json()),
onMutate: async (newTodo) => {
// 1️⃣ 取消任何进行中的todos查询,防止覆盖我们的乐观更新
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2️⃣ 备份当前状态以便回滚
const previousTodos = queryClient.getQueryData(['todos']);
// 3️⃣ 乐观更新:立即在列表中显示新Todo
queryClient.setQueryData(['todos'], (old) => [
...(old || []),
{
...newTodo,
id: Date.now(), // 临时ID
isPending: true,
},
]);
return { previousTodos };
},
onSuccess: (responseData) => {
// 服务器返回了真实的ID,更新缓存中临时项的ID
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.isPending ? { ...responseData, isPending: false } : todo
)
);
},
onError: (err, newTodo, context) => {
// 4️⃣ 请求失败,回滚到之前的状态
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
// 显示错误提示...
},
onSettled: () => {
// 5️⃣ 无论成功失败,都重新验证数据(确保和服务器同步)
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
function TodoApp() {
const { data: todos } = useTodos();
const addTodoMutation = useAddTodo();
return (
<div>
{todos?.map(todo => (
<div key={todo.id} style={{
opacity: todo.isPending ? 0.6 : 1,
}}>
{todo.text}
</div>
))}
<button onClick={() => {
addTodoMutation.mutate({
text: '新的Todo',
completed: false,
});
}}>
添加
</button>
</div>
);
}
这就是乐观更新。看起来代码更多了,但这段代码处理了所有边界情况:
手工用useState+useEffect实现这个?我见过开发者用一整个周末都没搞对。

TanStack Query维护的缓存有两个关键参数:
{
staleTime: 5 * 60 * 1000, // 「新鲜度」:数据5分钟内被认为是新的
gcTime: 10 * 60 * 1000, // 「生存期」:未使用的数据10分钟后从内存删除
}
这两个概念经常被混淆:
// 实战例子:电商商品详情页
useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 30 * 60 * 1000, // 商品信息30分钟内不变,不重新请求
gcTime: 60 * 60 * 1000, // 1小时内如果用户没看这个商品,清理缓存
});
这样的配置意味着:
很多人把invalidateQueries当成了「强制刷新」来用,这样就浪费了TanStack Query的优势。
// ❌ 过度使用,效率低
onSuccess: () => {
queryClient.invalidateQueries(); // 刷新所有查询!
}
// ✅ 精准控制,高效
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['products', categoryId], // 只刷新这个分类的商品
});
}
合理的废弃策略应该是:当你确实修改了某个数据(比如删除了一个商品),才主动标记相关查询为过期。其他时候让系统的staleTime机制自动处理。
如果你现在的React代码里充满了复杂的useEffect依赖和状态管理逻辑,那恭喜——你找到了问题所在。
这不是你的错,是工具选择的错。
TanStack Query不是一个额外的依赖,而是一笔划算的投资。它能让你:
最后一个建议:如果你的项目还在用useState来存放API数据,不妨这个周末试试TanStack Query。写完第一个hook的时候,你会感受到一种"原来可以这样写"的解脱感。
那个时刻,就是你真正理解了服务端状态和客户端状态的区别。