Две потенциальные проблемы промисов, которые скорее всего не увидишь во время разработки и очень тяжело повторить, разве что у тебя плохо написан сервер или очень флеки интернет.
Представим какую-то таб группу которая показывается таблицы с разными данными. При переключении между табами нужно загрузить и показать новую таблицу. Не вдаваясь в подробности, попробуем написать дата фетчинг по зову сердца и по первому попавшемуся примеру из документации Реакта:
function App() {
let [query, setQuery] = useState(null);
let [data, setData] = useState(null);
useEffect(() => {
fetchData(query).then((data) => setData(data));
}, [query]);
return (
<Fragment>
<RadioGroup onChange={(value) => setQuery(value)}>
<RadioOption label="Version A" value="A" />
<RadioOption label="Version B" value="B" />
<RadioOption label="Version C" value="C" />
</RadioGroup>
{data != null ? <DataTable data={data} /> : null}
</Fragment>
);
}
Последовательно нажимаем первую, вторую, третью вкладки, видим как появляются новые данные в таблице. Но что если сервер на самом деле выглядит вот так:
function fetchData(query) {
return new Promise((resolve) => {
let data = [];
// simulating latency
switch (query) {
case 'A':
setTimeout(resolve, 5 * 1000, data);
break;
case 'B':
setTimeout(resolve, 2 * 1000, data);
break;
case 'C':
setTimeout(resolve, 2 * 1000, data);
break;
}
});
}
Красота функциональных компонентов и хуков состоит в том что в коде нет понятия времени, всё декларативно. Если код легко читать, скорее всего он не может быть сильно поломан, иначе это было бы видно. Но промисы в джаваскрипте не такие, они пронзают весь пространственно-временной континуум. Чтобы понять потенциальный баг в примере выше, нужно представить что происходит с памятью когда UI делает новые запросы
time ->
fetch query A -------------------------
fetch query B ----------
fetch query C ----------
Если мы поочерёдно нажмем все три вкладки, в итоге мы увидим результат нажатия последней вкладки, но это будет ненадолго. Самый первый запрос в примере занимает чуть больше времени и к моменту когда он закончится его результаты нам уже не нужны. Но так уже получилось что промис остался в памяти, и в его скоупе оказался доступ к setData
, всё ещё валидный. Этот промис резолвится, setData вызывается с результатом первого запроса, интерфейс начинает показывать данные таблицы A независимо от того какая таба открыта на текущий момент.
Предположим, ситуация с race condition решилась через саспенс.
function App() {
let [query, setQuery] = useState(null);
let resource$ = useResource(DataResource, [query]);
return (
<Fragment>
<RadioGroup onChange={(value) => setQuery(value)}>
<RadioOption label="Version A" value="A" />
<RadioOption label="Version B" value="B" />
<RadioOption label="Version C" value="C" />
</RadioGroup>
<Suspense fallback={<Spinner />}>
<DataTable resource$={resource$} />
</Suspense>
</Fragment>
);
}
Но предположим что проблема с сервером всё та же, первый запрос будет занимать критически больше времени чем другие. Когда Саспенс вылавливает промис, скоуп этого элемента просто делает promise.then(render)
. В примере это будет промис таблицы А, которая занимает очень много времени. Если мы начнём переключаться между вкладками во время того как появился Саспенс для первой таблицы, нам всё равно придётся дождаться резолва этого первого промиса, потому что рендер который приведёт к новому Саспенсу ещё не может случиться.
Всех бы этих проблем не было, если бы у промисов был нормальный механим отмены.