Данни от API в компонент
Накратко: Данните от API в React компонент се зареждат от useEffect със състояние „зарежда“, „грешка“ или „готово“, а AbortController в почистването спира остарялата заявка.
Проблем
Под „Напитки“ има ябълки
Каталогът взима продуктите на категорията от API-то. Тук сървърът отговаря на „Плодове и зеленчуци“ за 1,5 секунди, а на другите категории за 0,3.
Конзола
Натисни „Плодове и зеленчуци“ и веднага „Напитки“. Появяват се напитките. Секунда по-късно на тяхно място идват ябълки, банани и домати, а заглавието и натиснатият бутон пак казват „Напитки“. Купувачът вижда грешни продукти и не знае защо.
Идеята
Заявката е ефект
Заявката излиза от компонента към сървъра. Затова е страничен ефект и мястото ѝ е в ефекта, с категорията като зависимост: всяка нова категория пуска нова заявка.
Състоянията са тези от урока за 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 продукта.
Конзола
Решение
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Категории">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
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]);
if (status === 'loading') return <p role="status">Зареждане…</p>;
if (status === 'error') return <p role="alert">Продуктите не се заредиха.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
Ефектът със зависимост [categoryId] вика load(), а тя слага 'loading', чака отговора и слага продуктите и 'ready'. Проверката хваща []: млечните се зареждат, а смяната на категорията не прави нищо.
Проверката хваща и 'loading', сложено само в началото на компонента: при смяна на категорията „Зареждане…“ не се появява.
Идеята
Надбягването
Всяко натискане сменя категорията и пуска ефекта наново. Всяко пускане праща своя заявка и чака своя отговор. Нито едно не знае за другите.
Отговорите не идват в реда на заявките. Сървърът може да търси плодовете по-дълго, мрежата може да забави едната. Затова, когато купувачът превключва бързо, две заявки са на път едновременно. Печели тази, която отговори последна, а не тази, която е тръгнала последна.
Това се казва надбягване (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]);Стъпка по стъпка, с думи
- Купувачът натиска „Плодове и зеленчуци“. Категорията се сменя, ефектът тръгва и праща заявка. Тази е бавна.
- Преди тя да отговори, купувачът натиска „Напитки“. Ефектът тръгва пак и праща втора заявка. Сега две заявки пътуват едновременно.
- Бързата заявка отговаря първа: списъкът показва напитките.
- Да кажем, че почистването липсва. Бавната заявка отговаря последна, нейното пускане също слага продуктите и под „Напитки“ има плодове.
- Сега с почистването. Всяко пускане на ефекта прави свой AbortController и подава неговия signal на fetch.
- Новата категория вика почистването на старото пускане: abort() спира бавната заявка. Нейният fetch хвърля AbortError, а catch го пропуска: това не е грешка.
- Отговаря само заявката за напитките. Списъкът и заглавието съвпадат.
- Правилото: заявка в ефект има отказ в почистването. Последното пускане печели, защото всички преди него са спрени.
Без почистване екранът показва последния отговор. С почистване старата заявка е спряна, преди да е отговорила, и последният отговор е винаги на последната категория.
Идеята
Отказ в почистването
Ефектът вече има място за „спри старото“: почистването. Браузърът има инструмент за спиране на заявка: AbortController.
- Всяко пускане на ефекта прави свой
controller = new AbortController(). - Подава неговия
controller.signalнаfetch:fetch(url, { signal: controller.signal }). - Почистването вика
controller.abort(). Заявката спира, аawait fetch(…)хвърля грешка с имеAbortError. 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]);Почистването върви и когато компонентът си отиде. Купувачът е излязъл от каталога: заявката спира и никой не слага продукти в компонент, който го няма.
Пробвай
Спри старата заявка
Направи така, че при бързо превключване старата заявка да спре. Проверката натиска „Плодове и зеленчуци“ и веднага „Напитки“, чака „Зареждане…“ и напитките, изчаква бавния отговор и гледа, че ябълки няма, а съобщение за грешка не се е появило.
Конзола
Решение
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Категории">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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]);
if (status === 'loading') return <p role="status">Зареждане…</p>;
if (status === 'error') return <p role="alert">Продуктите не се заредиха.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
Ефектът прави AbortController, подава signal на fetch и връща () => controller.abort(). catch пропуска AbortError.
Проверката хваща контролер, чийто signal не е подаден на fetch: abort() не спира нищо и ябълките пак идват. Хваща и catch, който не пропуска AbortError: веднага след „Напитки“ отказът слага 'error', и вместо „Зареждане…“ за миг се вижда „Продуктите не се заредиха.“
Пробвай
Намери грешката
Купувачът превключва бързо и за миг вижда „Продуктите не се заредиха.“, после идват продуктите. Сървърът не е върнал грешка. Кой ред е виновен?
Избери реда с грешката.
Ред 11: всяка грешка става 'error', и отказът от почистването също. Преди него трябва if (e.name === 'AbortError') return;: спряната заявка не е паднала.
Пробвай
Попълни
Какво спира старата заявка?
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(show);
return () => controller.();
}, [categoryId]);abort: controller.abort() спира заявката, чийто signal е подаден на fetch, и тя хвърля AbortError.
Идеята
Грешка и „Опитай пак“
Сървърът понякога отговаря с 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> ефектът тръгва, почиства се и тръгва пак. Затова в мрежата ще видиш две заявки, първата отказана. Това е проверката, че почистването работи.Упражнение
Опитай пак
Тук първата заявка за млечните получава 500. Каталогът показва „Продуктите не се заредиха.“, но няма как да опиташ пак. Добави бутон „Опитай пак“, който пуска зареждането наново. Отказът при бързо превключване трябва да остане. Проверката чака грешката, натиска „Опитай пак“, чака „Зареждане…“ и 5 продукта, после превключва бързо на плодовете и напитките.
Конзола
Решение
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Категории">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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, attempt]);
if (status === 'loading') return <p role="status">Зареждане…</p>;
if (status === 'error')
return (
<>
<p role="alert">Продуктите не се заредиха.</p>
<button type="button" onClick={() => setAttempt(attempt + 1)}>Опитай пак</button>
</>
);
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
const [attempt, setAttempt] = useState(0), зависимостите стават [categoryId, attempt], а при 'error' компонентът показва съобщението и бутон с onClick={() => setAttempt(attempt + 1)}. Проверката хваща бутон, който сменя attempt, когато attempt го няма в зависимостите: нищо не тръгва.
Проверката хваща и бутон, който само слага 'loading' без нова заявка, и код без проверката на response.ok: тогава отговорът с 500 се чете като списък с продукти.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Props: данни отгоре · Условия: празно, грешка, зарежда · Състояние · Ефекти
- Продължава в: Формуляри в компонент · Общо състояние · Тестове на компоненти · От пясъчника към проект
- Задачи по темата: Ефекти, данни и формуляри