React useDeferredValue

深入理解 useDeferredValue 的基本用法,与 useTransition 的本质区别,典型应用场景,以及使用时需要注意的工程判断。

React useDeferredValue

从一个实际场景开始

想象你在实现一个搜索功能。用户在输入框中输入关键词,页面上有一个大列表显示匹配结果。这个列表可能有 thousands of items,每次用户输入都要重新过滤和渲染。

如果用户的电脑不够快,输入可能会感到卡顿——因为每次击键都会触发大量的列表重渲染。

你可能会想到用 useTransition 来解决这个问题:

function Search({ items }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleInput(e) {
    setQuery(e.target.value);

    startTransition(() => {
      // 过滤逻辑在 transition 中
      setFilteredItems(filter(items, e.target.value));
    });
  }
}

这个方案有效。但它有一个限制:你必须在「设置 query」和「设置 filteredItems」的地方都写代码。如果 filteredItems 的计算逻辑分散在多个组件里,或者数据是从父组件传下来的,这种方式就不太方便了。

useDeferredValue 提供了一种更简洁的方式:不需要修改状态更新的位置,只需要在你需要「延迟使用」的值外面包一层 useDeferredValue


useDeferredValue 的基本用法

useDeferredValue 接受一个值作为参数,返回这个值的「延迟版本」:

const deferredQuery = useDeferredValue(query);

query 变化时,deferredQuery 可能暂时和 query 不同步——React 会优先保证 query 的更新,让 deferredQuery 稍后追上。

完整示例

import { useState, useDeferredValue } from 'react';

function Search({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const filteredItems = useMemo(() => {
    return items.filter(item =>
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [items, deferredQuery]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        {filteredItems.map(item => (
          <SearchResult key={item.id} item={item} />
        ))}
      </div>
    </div>
  );
}

在这个例子里:

  • 用户输入更新 query(紧急更新,立即响应)
  • filteredItems 的计算基于 deferredQuery(非紧急更新,可能延迟)
  • 如果 querydeferredQuery 不同步,说明 transition 还在进行中,可以显示加载态

useDeferredValue 与 useTransition:核心区别

这两个 API 都涉及「延迟」,但解决的问题不同:

useTransition useDeferredValue
作用对象 包装状态更新 包装已存在的值
使用方式 startTransition(() => setState()) const deferred = useDeferredValue(state)
典型场景 状态更新逻辑在当前组件 值来自 props 或复杂的计算结果
是否需要修改状态逻辑 需要把 setState 包在 startTransition 里 不需要,只需用 useDeferredValue 包装

什么时候用 useTransition

当你在同一个组件内有两个相关的状态更新,而你想让其中一个延迟:

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    setQuery(e.target.value);                    // 紧急
    startTransition(() => {
      setResults(filter(e.target.value));        // 非紧急
    });
  }
}

什么时候用 useDeferredValue

当你从外部接收一个值,而这个值的消费者应该被延迟:

function SearchResults({ query, items }) {
  // query 从父组件传来,无法修改它的 setState
  // 用 useDeferredValue 延迟对 query 的使用
  const deferredQuery = useDeferredValue(query);

  const filteredItems = useMemo(() => {
    return items.filter(item =>
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [items, deferredQuery]);

  return <List items={filteredItems} />;
}

一个形象的比喻

想象你在厨房做菜:

  • useTransition 像是告诉你的助手:「先洗菜(紧急),等洗完了再切菜(非紧急)」
  • useDeferredValue 像是给你一个「延迟版本」的菜谱——你看到的是当前菜谱,但助手可以稍后才参考这个菜谱去执行

典型场景:输入框 + 过滤大列表

useDeferredValue 最典型的场景是:用户输入变化很快,但结果的渲染可以稍微延迟

考虑一个通讯录应用,列表中有 thousands of contacts:

function ContactList({ contacts }) {
  const [searchTerm, setSearchTerm] = useState('');
  const deferredSearchTerm = useDeferredValue(searchTerm);

  const filteredContacts = useMemo(() => {
    // 基于 deferred 的值过滤,渲染可以更流畅
    return contacts.filter(contact =>
      contact.name.toLowerCase().includes(deferredSearchTerm.toLowerCase()) ||
      contact.email.toLowerCase().includes(deferredSearchTerm.toLowerCase())
    );
  }, [contacts, deferredSearchTerm]);

  const isStale = searchTerm !== deferredSearchTerm;

  return (
    <div>
      <input
        value={searchTerm}
        onChange={e => setSearchTerm(e.target.value)}
        placeholder="Search contacts..."
      />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        {filteredContacts.map(contact => (
          <ContactCard key={contact.id} contact={contact} />
        ))}
      </div>
    </div>
  );
}

这个模式的优势:

  • 输入本身是流畅的,每次击键都立即响应
  • 列表的过滤和渲染会被延迟处理
  • 如果用户快速连续输入,过滤会「跟不上」,但这没关系——用户看到的是旧结果(而不是卡顿的输入)

为什么 deferredValue 和原始值可能不同步

useDeferredValue 返回的延迟值和原始值可能暂时不同步。这是设计如此,不是 bug。

const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

// 初始状态:query = '', deferredQuery = ''
// 用户输入 'a':query = 'a', deferredQuery 可能还是 ''
// React 在处理完紧急更新后,才会更新 deferredQuery
// 如果用户继续输入 'ab',query = 'ab', deferredQuery 可能还是 '' 或刚变成 'a'

React 这样做是为了优先保证紧急更新的响应性。如果每次 query 变化都要同步更新 deferredQuery,那就失去了「延迟」的意义。


使用时注意:不要把应该同步的逻辑延迟了

useDeferredValue 不是万能的。如果两个值之间有依赖关系,延迟其中一个可能导致问题:

错误示例:deferred 值用在应该同步的地方

function ProductPage({ productId }) {
  const [selectedVariant, setSelectedVariant] = useState(null);
  const deferredVariant = useDeferredValue(selectedVariant);

  // 错误:价格显示用了 deferred 的值
  // 如果用户切换 variant,价格可能显示旧的值
  return (
    <div>
      <ProductDetails productId={productId} variant={deferredVariant} />
      <Price variant={deferredVariant} />  // 可能显示旧价格!
      <AddToCartButton variant={deferredVariant} /> // 可能用旧 variant
    </div>
  );
}

正确示例:只在渲染列表时用 deferred 值

function ProductPage({ productId }) {
  const [selectedVariant, setSelectedVariant] = useState(null);
  const deferredVariant = useDeferredValue(selectedVariant);

  return (
    <div>
      <ProductDetails productId={productId} variant={selectedVariant} />
      {/* 价格的计算和显示是同步的 */}
      <Price variant={selectedVariant} />

      {/* 评论区可能有大量评论,用 deferred 版本延迟渲染 */}
      <CommentSection productId={productId} variant={deferredVariant} />
      <AddToCartButton variant={selectedVariant} />
    </div>
  );
}

工程判断原则

useDeferredValue 时,问自己一个问题:这个值的「旧」版本会不会导致错误的用户界面?

  • 如果答案是「不会」——比如搜索结果列表显示旧结果没问题——那么可以用 deferred value
  • 如果答案是「会」——比如价格显示错误、按钮功能错误——那么不应该用 deferred value

Suspense 的结合:deferredValue 和 Suspense 配合

useDeferredValueSuspense 配合可以创造更流畅的用户体验:

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);

  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <SearchResultsContent query={deferredQuery} />
    </Suspense>
  );
}

这里的逻辑是:

  • 如果 deferredQueryquery 落后很多,React 认为这是一个 "delayed" 的 render
  • Suspense 会等待一段时间,看看 deferredQuery 能不能追上
  • 如果 deferredQuery 追上来了,直接显示内容(没有 loading 闪烁)
  • 如果 deferredQuery 迟迟追不上,Suspense 显示 fallback

这种模式和传统的「显示 loading 等数据」不同——它创造了一种「先显示旧内容,等新内容准备好了再更新」的感觉。


useDeferredValue 的内部机制

理解 useDeferredValue 的实现有助于更好地使用它。

useDeferredValue 本质上是React 的调度机制的一种应用:

  1. query 变化时,React 立即开始处理这个更新(紧急)
  2. React 同时调度一个「延迟更新」来更新 deferredQuery
  3. 在渲染时,React 使用 deferredQuery 来渲染列表
  4. 如果期间有新的紧急更新(用户继续输入),React 会优先处理
  5. deferredQuery 的更新可能被多次「跳过」,直到最终追上 query

这意味着 deferredQuery 的更新是**「best effort」**的——React 会尽力让它追上原始值,但如果系统繁忙,它可能会落后更多。这不是 bug,是 React 在资源受限时的合理降级策略。

和 useTransition 的关系

在 React 内部,useDeferredValue 的实现可能使用或类似于 useTransition 的机制。实际上,如果你看 React 18 的实现,useDeferredValue 内部就是用类似 useTransition 的方式来延迟状态传播的。

但两者面向的抽象层次不同:

  • useTransition 让你控制「哪些 setState 是紧急的,哪些不是」
  • useDeferredValue 让你控制「这个值的消费者应该被延迟」

常见误区

误区一:deferred value 总是落后于原始值

实际上,deferredQuery 在大多数情况下会很快追上 query。只有在连续快速输入时,它才会明显落后。如果用户停顿一下输入,deferredQuery 会很快变成和 query 一样。

误区二:可以用 useDeferredValue 来「缓存」计算结果

useDeferredValue 不是缓存工具。它不存储计算结果,只是延迟值的传播。如果你想要缓存,用 useMemouseCallback

误区三:所有列表渲染都应该用 useDeferredValue

过度使用 useDeferredValue 可能会导致 UI 看起来「慢半拍」。只有在确实有性能问题(列表很大、渲染很慢)时才需要用。


面试中的表达

面试官问 useDeferredValue,通常想确认你理解它和 useTransition 的区别:

useDeferredValue 和 useTransition 都涉及「延迟」,但解决的问题不同。useTransition 让你把一个状态更新标记为非紧急的,用在「状态更新逻辑在同一个组件里」的场景。useDeferredValue 让你延迟「值的使用」,用在「值从外部传来,无法修改状态更新逻辑」的场景。

它们的本质区别是:useTransition 控制的是「生产端」(setState 被标记为非紧急),useDeferredValue 控制的是「消费端」(值的使用被延迟)。useDeferredValue 不改变状态更新的时机,只改变状态值「传播到消费者」的时机。

工程判断的关键是:deferred 版本的值如果显示旧内容,用户能接受吗?如果能,用 useDeferredValue;如果不能,就不要用。


延展阅读