Пошук уроків, статей та іншого контенту
Створите повторно використовуваний хук для запитів із єдиним інтерфейсом даних, завантаження та помилок.
Компонент, який виконує запит до API, зазвичай має зберігати щонайменше три значення:
data — отримані дані;
loading — ознака виконання запиту;
error — інформація про помилку.
Якщо логіку запитів реалізовувати безпосередньо в кожному компоненті, код швидко дублюється. Власний хук дозволяє винести цю логіку в одне місце та використовувати однаковий інтерфейс у різних компонентах.
Приклад інтерфейсу хука:
const { data, loading, error, execute } = useApi();Компоненту не потрібно знати, як саме створюється AbortController, перевіряється HTTP-статус або обробляється помилка.
useApiВласний хук — це функція, назва якої починається з use і яка може використовувати інші хуки React.
import { useCallback, useEffect, useRef, useState } from "react";
export function useApi(initialData = null) {
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const execute = useCallback(async (url, options = {}) => {
// Скасовуємо попередній запит, якщо він ще виконується
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`Запит завершився з кодом ${response.status}`
);
}
const result = await response.json();
// Не оновлюємо стан, якщо запит було скасовано
if (!controller.signal.aborted) {
setData(result);
setLoading(false);
}
return result;
} catch (caughtError) {
// Скасування запиту не вважаємо помилкою для користувача
if (caughtError.name === "AbortError") {
return null;
}
if (!controller.signal.aborted) {
const normalizedError =
caughtError instanceof Error
? caughtError
: new Error("Невідома помилка");
setError(normalizedError);
setLoading(false);
}
return null;
}
}, []);
useEffect(() => {
return () => {
// Скасовуємо запит перед видаленням компонента
abortControllerRef.current?.abort();
};
}, []);
return {
data,
loading,
error,
execute,
};
}Зберігає дані, стан завантаження та помилку в окремому стані.
Приймає URL і додаткові параметри для fetch.
Перевіряє response.ok, оскільки fetch не вважає HTTP-коди 4xx і 5xx винятками.
Скасовує попередній запит перед запуском нового.
Скасовує активний запит під час видалення компонента.
Повертає єдиний об’єкт для використання в компонентах.
Нижче компонент завантажує список користувачів після монтування та дозволяє повторити запит кнопкою.
import { useCallback, useEffect } from "react";
import { useApi } from "./useApi";
export default function UsersList() {
const {
data: users,
loading,
error,
execute,
} = useApi([]);
const loadUsers = useCallback(() => {
return execute("https://jsonplaceholder.typicode.com/users");
}, [execute]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
if (loading && users.length === 0) {
return <p>Завантаження користувачів...</p>;
}
if (error) {
return (
<section>
<p>Не вдалося завантажити користувачів: {error.message}</p>
<button type="button" onClick={loadUsers}>
Повторити
</button>
</section>
);
}
return (
<section>
<button type="button" onClick={loadUsers} disabled={loading}>
{loading ? "Оновлення..." : "Оновити список"}
</button>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</section>
);
}Початкове значення [] важливе в цьому прикладі. Завдяки йому users.map(...) можна викликати одразу, не перевіряючи, чи є users масивом.
Другий аргумент execute передається до fetch, тому хук можна використовувати не лише для GET-запитів.
await execute("/api/articles", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Новий матеріал",
content: "Текст матеріалу",
}),
});Наприклад, компонент може викликати цей запит після натискання кнопки:
import { useApi } from "./useApi";
export default function CreateArticle() {
const { loading, error, execute } = useApi();
async function handleSubmit() {
const article = await execute("/api/articles", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Новий матеріал",
content: "Текст матеріалу",
}),
});
if (article) {
console.log("Матеріал створено:", article);
}
}
return (
<section>
<button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? "Збереження..." : "Створити матеріал"}
</button>
{error && <p>{error.message}</p>}
</section>
);
}useCallbackФункція execute обгорнута в useCallback, тому її посилання залишається стабільним між повторними рендерами.
Це важливо, коли функція використовується в залежностях useEffect:
const loadUsers = useCallback(() => {
return execute("/api/users");
}, [execute]);
useEffect(() => {
loadUsers();
}, [loadUsers]);Без мемоізації функція loadUsers створювалася б під час кожного рендера. Якщо передати її безпосередньо в залежності ефекту, це могло б призвести до повторного виконання ефекту після кожного рендера.
AbortController використовується у двох ситуаціях:
користувач запускає новий запит до завершення попереднього;
компонент видаляється зі сторінки.
Скасування запиту допомагає уникнути ситуації, коли старий запит завершується пізніше та перезаписує результат новішого запиту.
Наприклад, користувач може швидко змінити фільтр:
execute("/api/products?search=phone");
execute("/api/products?search=laptop");Перед другим запитом перший буде скасовано. Тому результат для phone не повинен замінити актуальний результат для laptop.
Зазвичай компонент має відображати різні стани:
Початкове завантаження.
Успішно отримані дані.
Повторне завантаження.
Помилку.
Порожній результат.
Важливо розрізняти початкове завантаження та оновлення вже завантажених даних:
if (loading && data === null) {
return <p>Початкове завантаження...</p>;
}Якщо data уже містить результат, можна залишити його на екрані, показавши поруч індикатор оновлення:
return (
<>
{loading && <p>Оновлення даних...</p>}
{/* Відображення вже завантажених даних */}
</>
);У наведеному хуку під час нового запиту старі дані не видаляються. Це дозволяє не показувати порожній екран під час кожного оновлення.
Неправильно:
if (shouldLoad) {
const api = useApi();
}Хуки потрібно викликати на верхньому рівні компонента або іншого хука:
const api = useApi();
if (shouldLoad) {
api.execute("/api/data");
}fetch відхиляє проміс переважно через мережеві помилки. Відповіді зі статусами 404 або 500 потрібно перевіряти вручну за допомогою response.ok.
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}Якщо компонент уже видалено, завершення запиту не повинно оновлювати його стан. Саме для цього хук скасовує запит у функції очищення useEffect і перевіряє controller.signal.aborted.
Якщо компонент очікує масив, краще створити хук із початковим масивом:
const { data: products } = useApi([]);Тоді можна безпечно використовувати:
products.map((product) => (
<li key={product.id}>{product.name}</li>
));Не слід запускати execute безпосередньо в тілі компонента:
function UsersList() {
const api = useApi();
api.execute("/api/users");
return null;
}Це створює цикл рендерів і запитів. Автоматичне завантаження потрібно запускати в useEffect.
Власний хук useApi:
приховує повторювану логіку роботи з fetch;
надає єдиний інтерфейс data, loading, error і execute;
підтримує GET-, POST- та інші HTTP-запити;
обробляє помилки HTTP і мережі;
скасовує застарілі запити;
може використовуватися в різних компонентах без дублювання коду.
Такий підхід робить компоненти простішими: вони відповідають за відображення інтерфейсу, а хук — за виконання запитів і керування їхнім станом.