useDeferredValue

Хук useDeferredValue позволяет откладывать обновление для части UI.

const deferredValue = useDeferredValue(value)

Справочник

useDeferredValue(value, initialValue?)

Чтобы сделать обновления значения отложенными, вызовите useDeferredValue с этим значением на верхнем уровне своего компонента:

import { useState, useDeferredValue } from 'react';

function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// ...
}

См. другие примеры ниже.

Параметры

  • value: Значение, обновление которого вы хотите отложить.
  • Canary only необязательный initialValue: Значение, установленное для первого рендера. Если этот параметр опущен, useDeferredValue не сработает для первого рендера, так как нет предыдущей версии value, которую можно было бы показать.

Возвращаемое значение

  • currentValue: При первом рендеринге вызов вернёт то же значение, которое вы указали. Когда в следующих обновлениях значение изменится, вызов вернёт прошлое значение, но при этом React запустит дополнительный фоновый рендеринг, в котором вызов вернёт обновлённое значение.

Canary

В последних canary-версиях React, useDeferredValue возвращает initialValue для первого рендера, и в фоновом режиме планирует последующий рендер уже с value.

Замечания

  • Когда обновление происходит внутри перехода (Transition), оно уже отложено, поэтому useDeferredValue всегда возвращает новое значение value и не приводит к повторному рендеру.

  • Значения, которые вы передаёте в useDeferredValue, должны либо быть примитивного типа (как, например, строки или числа), либо должны создаваться не во время рендеринга. Если вы будете во время рендеринга каждый раз передавать в useDeferredValue свеже созданный объект, то так вы будете постоянно запускать ненужный фоновый рендеринг.

  • Когда useDeferredValue получит другое значение (сравниваться будет через Object.is), помимо текущего рендеринга (в котором хук вернёт старое значение), дополнительно в фоне запустится рендеринг для собственно нового значения. Но этот фоновый рендеринг может прерваться: если значение параметра value изменится ещё раз, то React перезапустит фоновый рендеринг заново. Например, если пользователь будет печатать быстрее, чем зависящий от ввода график будет успевать в фоне рендерить предыдущий ввод — график в таком случае обновится, только когда пользователь перестанет печатать.

  • useDeferredValue интегрирован с <Suspense>. Если фоновое обновление для нового значения задержится, то вместо заглушки <Suspense> пользователь просто увидит старое значение, пока загружаются данные для фонового обновления.

  • Сам по себе useDeferredValue не защищает от лишних запросов в сеть.

  • useDeferredValue не пытается отложить обновление на какое-то конкретное количество времени. Как только React закончит с текущим рендерингом, он сразу же запустит в фоне рендеринг для новой версии отложенного значения. А любые обновления из-за внешних событий (пользователь печатает, например), будут просто более приоритетными, чем фоновый рендеринг, и прервут его.

  • Эффекты фонового рендеринга, вызванного useDeferredValue, сработают, только когда React зафиксирует результат на экране. Если фоновый рендеринг запросит задержку, то эффекты сработают только после того, как данные загрузятся, а экран обновится.


Применение

Отображение старых данных, пока загружаются новые

Чтобы отложить обновление для части UI, вызовите useDeferredValue на верхнем уровне своего компонента:

import { useState, useDeferredValue } from 'react';

function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// ...
}

При первом рендеринге отложенное значение будет равно значению, которое вы передадите.

В следующих обновлениях отложенное значение будет как бы “отставать” от актуального значения. А именно: сначала React отрендерит компонент, не обновляя отложенное значение, а затем в фоне попытается отрендерить компонент с новым значением.

Разберём на примере, когда это может быть полезно.

Note

Предполагается, что данные в этом примере вы получаете через источники, которые поддерживают Suspense:

  • Запрашиваете данные с помощью поддерживающих Suspense фреймворков, как, например, Relay или Next.js.
  • Лениво загружаете код компонентов с помощью lazy.
  • Читаете значение промиса с помощью use.

Подробнее о Suspense и связанных с ним ограничениях.

В этом примере компонент SearchResults задерживается, т.к. отправляет поисковый запрос. Попробуйте ввести "a", дождаться результатов поиска, и затем ввести "ab". На месте результатов по запросу "a" ненадолго появится индикатор загрузки.

import { Suspense, useState } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
  const [query, setQuery] = useState('');
  return (
    <>
      <label>
        Найти альбом:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Загрузка...</h2>}>
        <SearchResults query={query} />
      </Suspense>
    </>
  );
}

Однако здесь можно применить другой частый паттерн в UI: отложить обновление списка результатов, продолжив показывать старые результаты, пока не подготовятся новые. Чтобы показать результаты поиска по отложенной версии запроса, можно применить useDeferredValue:

export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<label>
Найти альбом:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Загрузка...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}

Значение query будет всегда актуальным — соответственно и отображаемый в поле ввода запрос. Но в deferredQuery будет предыдущее значение запроса, пока не загрузятся новые результаты поиска — поэтому SearchResults ещё некоторое время будет показывать старые результаты.

В изменённом примере ниже введите "a", дождитесь загрузки результатов поиска, и затем измените запрос на "ab". Обратите внимание, что теперь, пока загружаются новые результаты, вместо индикатора загрузки (заглушки Suspense) отображаются предыдущие результаты.

import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <label>
        Найти альбом:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Загрузка...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}

Deep Dive

Как работает отложенное обновление значения?

Для простоты удобно представлять, что обновление происходит в два этапа:

  1. Сначала React отрендерит компонент с новым запросом "ab" в query, но пока что с отложенным "a" в deferredQuery. Значение в deferredQuery, которое вы передаёте в список результатов, является отложенным: оно “отстаёт” от значения query.

  2. Затем в фоне React попытается ещё раз отрендерить компонент, но уже с новым запросом "ab" и в query, и в deferredQuery. Если этот рендеринг выполнится до конца, то React отобразит его результаты на экране. Но если рендеринг задержится (встанет в ожидании результатов для "ab"), то React эту конкретную попытку прервёт, а когда результаты загрузятся, попробует снова. Пока данные не загрузились, пользователю будет показываться старое отложенное значение.

Отложенный фоновый рендеринг можно прервать. Если, например, продолжить печатать запрос, React прервёт фоновый рендеринг и перезапустит его уже с новым вводом. React всегда будет ориентироваться только на самое последнее переданное ему значение.

В этом примере важно обратить внимание, что запросы в сеть всё ещё отправляются по каждому нажатию на клавиатуре. Откладывается здесь именно обновление результатов на экране, а не отправка в сеть запроса поиска. Просто запрос по каждому нажатию кэшируется — поэтому по удалению символа результат уже без запроса мгновенно берётся из кэша.


Подсветка неактуальных данных

В предыдущем примере в списке последних результатов никак не обозначалось, что результаты по новому запросу всё ещё загружаются. Такой интерфейс может сбить с толку, особенно если новые результаты будут загружаться долго. Решить проблему можно, добавив визуальную индикацию для случая, когда отображаемый список результатов больше не актуален и не соответствует последнему запросу:

<div style={{
opacity: query !== deferredQuery ? 0.5 : 1,
}}>
<SearchResults query={deferredQuery} />
</div>

Благодаря этим изменениям, когда вы начнёте набирать новый запрос, список старых результатов потускнеет, пока не загрузится новый список. Вы даже можете добавить анимированный переход с задержкой, чтобы визуально “устаревание” ощущалось постепенным. Например:

import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  return (
    <>
      <label>
        Найти альбом:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Загрузка...</h2>}>
        <div style={{
          opacity: isStale ? 0.5 : 1,
          transition: isStale ? 'opacity 0.2s 0.2s linear' : 'opacity 0s 0s linear'
        }}>
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}


Откладывание повторного рендеринга для части UI

useDeferredValue — это в том числе инструмент оптимизации. Его можно применить в ситуации, когда какая-то часть вашего UI требует вычислительно долгого рендеринга, с которым очень трудно что-то сделать, но при этом вы не хотите из-за этого постоянно блокировать рендеринг остального UI.

Представьте, что в вашем приложении некий сложный компонент (график, либо очень длинный список) каждый раз заново рендерится по каждому нажатию клавиши в поле ввода:

function App() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={text} />
</>
);
}

Для начала, вы можете обернуть SlowList в memo, чтобы рендеринг SlowList не повторялся, если его пропсы не меняются.

const SlowList = memo(function SlowList({ text }) {
// ...
});

Но этого не достаточно. Ведь так рендеринг ускорится, только если всегда передавать в SlowList одни и те же значения пропсов. Проблема в том, что рендеринг всё ещё медленный, если передавать другие значения пропсов, требующие другой визуализации.

Конкретно в этом примере, SlowList будет на каждый ввод символа получать новые пропсы и своим рендерингом блокировать остальной интерфейс. Из-за чего ввод будет слишком заметно “заедать”. В такой ситуации с помощью useDeferredValue можно сделать обновления поля ввода всегда более приоритетными (отзывчивее), чем обновления списка (которые в любом случае медленные):

function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}

Собственно сам рендеринг SlowList не станет от этого быстрее. Однако теперь React понимает, что не нужно блокировать обработку нажатий рендерингом списка. Визуально список будет как бы “отставать” от ввода, а затем его “догонять”. Конечно, как и до оптимизации, React будет стараться обновлять список как можно раньше, но уже не в ущерб возможности печатать.

Сравнение: useDeferredValue против неоптимизированного рендеринга

Example 1 of 2:
Отложенный рендеринг списка

В этом примере каждый элемент в компоненте SlowList искусственно замедлен, чтобы продемонстрировать, как useDeferredValue позволяет сохранить отзывчивость поля ввода. Попробуйте попечатать в поле ввода — оцените свои ощущения от того, как мгновенно оно реагирует на ввод, хотя список при этом заметно отстаёт.

import { useState, useDeferredValue } from 'react';
import SlowList from './SlowList.js';

export default function App() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <SlowList text={deferredText} />
    </>
  );
}

Pitfall

Для такой оптимизации SlowList должен обязательно быть обёрнут в memo. Цель оптимизации ведь в том, чтобы при каждом изменении text React рендерил родительский компонент (App) как можно быстрей. Во время этого рендеринга в deferredText будет такое же значение, как и в прошлый рендеринг — а значит пропсы SlowList не изменились, и нет нужды заново рендерить список. Но без memo список всё равно будет рендерится ещё раз — что делает саму оптимизацию бессмысленной.

Deep Dive

Чем отложенное обновление отличается от дебаунсинга и тротлинга?

Возможно, вы в похожей ситуации применили бы один из двух распространённых приёмов:

  • Дебаунсинг (debouncing), при котором приложение сначала бы дожидалось, когда пользователь перестанет печатать (уже секунду не печатал, например), и потом обновляло список.
  • Тротлинг (throttling), при котором, как бы быстро пользователь ни печатал, приложение обновляло бы список не чаще одного раза за какой-то период (раз в секунду, например).

Хотя эти методы полезны в некоторых случаях, useDeferredValue лучше подходит для оптимизации рендеринга, поскольку он тесно взаимодействует с React и может подстроиться под возможности устройства пользователя.

Можно не привязываться к какой-то фиксированной задержке. У пользователей с быстрым, мощным устройством фоновый рендеринг будет выполняться быстро и без заметной задержки. А у пользователей со слабым устройством список будет “отставать” ровно на столько, на сколько позволяет устройство.

Кроме того, в отличие от дебаунсинга и тротлинга, отложенный с помощью useDeferredValue рендеринг можно прервать. Это значит, что если, например, пользователь введёт очередной символ, пока в фоне рендерится большой сложный список, React прервёт этот рендеринг, обработает ввод, и затем снова запустит рендеринг в фоне. При этом с дебаунсингом или тротлингом в такой же ситуации интерфейс всё ещё будет тормозить и заедать — ведь эти приёмы не устраняют собственно блокировку ввода: с ними она случается просто либо позже, либо реже.

Когда нужно оптимизировать что-то помимо рендеринга, дебаунсинг и тротлинг могут наоборот быть очень полезны. Например, они помогут уменьшить количество запросов в сеть. А ещё их можно совмещать с описанными здесь техниками.