Към съдържанието
wannacode

Данни от API в компонент

Накратко: Данните от API в React компонент се зареждат от useEffect със състояние „зарежда“, „грешка“ или „готово“, а AbortController в почистването спира остарялата заявка.

Проблем

Под „Напитки“ има ябълки

Каталогът взима продуктите на категорията от API-то. Тук сървърът отговаря на „Плодове и зеленчуци“ за 1,5 секунди, а на другите категории за 0,3.

React

Натисни „Плодове и зеленчуци“ и веднага „Напитки“. Появяват се напитките. Секунда по-късно на тяхно място идват ябълки, банани и домати, а заглавието и натиснатият бутон пак казват „Напитки“. Купувачът вижда грешни продукти и не знае защо.

Идеята

Заявката е ефект

Заявката излиза от компонента към сървъра. Затова е страничен ефект и мястото ѝ е в ефекта, с категорията като зависимост: всяка нова категория пуска нова заявка.

Състоянията са тези от урока за fetch в страницата: зарежда, грешка, готово. Тук те са едно състояние, status, а компонентът рисува клона, който му отговаря, като в урока за условното показване.

useEffect(() => {
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
setStatus('error');
}
}
load();
}, [categoryId]);

Функцията на ефекта не може да е async: тя трябва да върне почистването, а async функция връща обещание. Затова load е отделна async функция вътре в ефекта, извикана веднага.

status става 'loading' в началото на всяко зареждане, а не само първия път. Иначе при смяна старият списък стои под новото заглавие, докато дойде отговорът.

Пробвай

Зареди категорията

Catalog има състоянията products и status, но нищо не ги пълни. Зареди продуктите на categoryId от /api/products?category=… и покажи „Зареждане…“ при всяко зареждане. Проверката гледа млечните (5 продукта), после натиска „Напитки“ и чака „Зареждане…“ и 4 продукта.

React

Идеята

Надбягването

Всяко натискане сменя категорията и пуска ефекта наново. Всяко пускане праща своя заявка и чака своя отговор. Нито едно не знае за другите.

Отговорите не идват в реда на заявките. Сървърът може да търси плодовете по-дълго, мрежата може да забави едната. Затова, когато купувачът превключва бързо, две заявки са на път едновременно. Печели тази, която отговори последна, а не тази, която е тръгнала последна.

Това се казва надбягване (race condition). То не личи, докато сървърът е бърз и еднакъв. Личи при бавна мрежа, на телефон, при натоварен сървър.

Виж го

Виж две заявки на път

Лентите растат, докато заявката пътува. Първо без почистване, после с него.

useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const url = `/api/products?category=${categoryId}`;
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId]);
Стъпка по стъпка, с думи
  1. Купувачът натиска „Плодове и зеленчуци“. Категорията се сменя, ефектът тръгва и праща заявка. Тази е бавна.
  2. Преди тя да отговори, купувачът натиска „Напитки“. Ефектът тръгва пак и праща втора заявка. Сега две заявки пътуват едновременно.
  3. Бързата заявка отговаря първа: списъкът показва напитките.
  4. Да кажем, че почистването липсва. Бавната заявка отговаря последна, нейното пускане също слага продуктите и под „Напитки“ има плодове.
  5. Сега с почистването. Всяко пускане на ефекта прави свой AbortController и подава неговия signal на fetch.
  6. Новата категория вика почистването на старото пускане: abort() спира бавната заявка. Нейният fetch хвърля AbortError, а catch го пропуска: това не е грешка.
  7. Отговаря само заявката за напитките. Списъкът и заглавието съвпадат.
  8. Правилото: заявка в ефект има отказ в почистването. Последното пускане печели, защото всички преди него са спрени.

Без почистване екранът показва последния отговор. С почистване старата заявка е спряна, преди да е отговорила, и последният отговор е винаги на последната категория.

Идеята

Отказ в почистването

Ефектът вече има място за „спри старото“: почистването. Браузърът има инструмент за спиране на заявка: AbortController.

  1. Всяко пускане на ефекта прави свой controller = new AbortController().
  2. Подава неговия controller.signal на fetch: fetch(url, { signal: controller.signal }).
  3. Почистването вика controller.abort(). Заявката спира, а await fetch(…) хвърля грешка с име AbortError.
  4. catch пропуска тази грешка: if (e.name === 'AbortError') return;. Заявката не е паднала, кодът сам я е спрял.
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(url, { signal: controller.signal });
…
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId]);

Почистването върви и когато компонентът си отиде. Купувачът е излязъл от каталога: заявката спира и никой не слага продукти в компонент, който го няма.

Пробвай

Спри старата заявка

Направи така, че при бързо превключване старата заявка да спре. Проверката натиска „Плодове и зеленчуци“ и веднага „Напитки“, чака „Зареждане…“ и напитките, изчаква бавния отговор и гледа, че ябълки няма, а съобщение за грешка не се е появило.

React

Пробвай

Намери грешката

Купувачът превключва бързо и за миг вижда „Продуктите не се заредиха.“, после идват продуктите. Сървърът не е върнал грешка. Кой ред е виновен?

Избери реда с грешката.

Пробвай

Попълни

Какво спира старата заявка?

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal }).then(show);
  return () => controller.();
}, [categoryId]);

Идеята

Грешка и „Опитай пак“

Сървърът понякога отговаря с 500. Проверката на response.ok хвърля, catch слага 'error', а компонентът показва съобщението с role="alert". Купувачът трябва да може да опита пак, без да презарежда страницата.

Опитът наново е същото зареждане. Ефектът вече го прави, когато се смени зависимост. Затова „Опитай пак“ сменя зависимост: число attempt, което само брои опитите.

const [attempt, setAttempt] = useState(0);
useEffect(() => {
// … същото зареждане
}, [categoryId, attempt]);
<button type="button" onClick={() => setAttempt(attempt + 1)}>Опитай пак</button>

Новото пускане слага 'loading', така че съобщението и бутонът изчезват, докато заявката пътува.

По-точно, за любопитните

Библиотеки за данни. В истински проект зареждането рядко се пише на ръка във всеки компонент. Има библиотеки, които правят това и още: пазят отговорите в кеш, не пращат две еднакви заявки, опитват пак сами, обновяват данните, когато купувачът се върне на раздела. За React най-често това са TanStack Query и SWR. Те не са в пясъчника и курсът няма уроци за тях. Под капака правят същото като тук: заявка в ефект, състояния, отказ.

Флаг вместо отказ. Вместо AbortController почистването може да вдигне флаг: let ignore = false; в ефекта, ignore = true в почистването и if (!ignore) преди да се сложат продуктите. Старата заявка пак стига до сървъра, но отговорът ѝ се изхвърля. Отказът спира и самата заявка. Двата начина решават надбягването.

StrictMode. В режим на разработка с <StrictMode> ефектът тръгва, почиства се и тръгва пак. Затова в мрежата ще видиш две заявки, първата отказана. Това е проверката, че почистването работи.

Упражнение

Опитай пак

Тук първата заявка за млечните получава 500. Каталогът показва „Продуктите не се заредиха.“, но няма как да опиташ пак. Добави бутон „Опитай пак“, който пуска зареждането наново. Отказът при бързо превключване трябва да остане. Проверката чака грешката, натиска „Опитай пак“, чака „Зареждане…“ и 5 продукта, после превключва бързо на плодовете и напитките.

React

Тест

Провери се

1. Купувачът натиска „Плодове и зеленчуци“, после „Напитки“. Отговорите идват в обратен ред. Какво показва код без почистване?
2. Защо заявката е в async function load() вътре в ефекта, а не useEffect(async () => …)?
3. Защо catch пропуска AbortError, вместо да покаже „Продуктите не се заредиха.“?
4. Как „Опитай пак“ пуска заявката наново?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·