Пошук уроків, статей та іншого контенту
Створите власний Hook для роботи з асинхронними даними, станами завантаження, помилок і результату.
Створимо власний Hook useAsync, який буде керувати асинхронною операцією та повертати:
data — успішний результат;
loading — ознаку виконання операції;
error — помилку;
execute — функцію для повторного запуску операції.
Hook буде універсальним: він не знатиме, чи виконується fetch, запит до іншого API або будь-яка інша асинхронна операція.
Зручно передавати в Hook функцію, яка приймає AbortSignal:
const loadData = async (signal) => {
const response = await fetch("/api/data", { signal });
if (!response.ok) {
throw new Error("Не вдалося завантажити дані");
}
return response.json();
};AbortSignal потрібен, щоб скасувати попередній запит, якщо:
компонент буде демонтовано;
користувач швидко запустить повторний запит;
параметри запиту зміняться, і старий результат більше не актуальний.
Hook має гарантувати, що застарілий запит не перезапише результат новішого запиту.
useAsyncСтворимо файл useAsync.js:
import { useCallback, useEffect, useRef, useState } from "react";
export function useAsync(asyncFunction, immediate = true) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const controllerRef = useRef(null);
const requestIdRef = useRef(0);
const execute = useCallback(async () => {
const requestId = ++requestIdRef.current;
// Скасовуємо попередній запит, якщо він ще виконується
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setLoading(true);
setError(null);
try {
const result = await asyncFunction(controller.signal);
// Старий запит не має права змінювати стан
if (
requestId === requestIdRef.current &&
!controller.signal.aborted
) {
setData(result);
}
return result;
} catch (caughtError) {
// Скасування запиту не вважаємо помилкою для користувача
if (caughtError.name === "AbortError") {
return;
}
if (requestId === requestIdRef.current) {
setError(caughtError);
}
throw caughtError;
} finally {
if (
requestId === requestIdRef.current &&
!controller.signal.aborted
) {
setLoading(false);
}
}
}, [asyncFunction]);
useEffect(() => {
if (!immediate) {
return;
}
execute();
return () => {
controllerRef.current?.abort();
};
}, [execute, immediate]);
return {
data,
error,
loading,
execute,
};
}loading встановлюється в true перед початком операції.
Попередній AbortController скасовується.
Для нового запиту створюється власний AbortController.
Успішний результат записується в data.
Помилка записується в error.
Помилка AbortError ігнорується, оскільки скасування — це очікувана поведінка.
requestIdRef захищає стан від результатів застарілих запитів.
Під час демонтування компонента поточний запит скасовується.
fetchРозглянемо компонент, який завантажує користувача з API.
import { useCallback } from "react";
import { useAsync } from "./useAsync";
function UserProfile({ userId }) {
const loadUser = useCallback(async (signal) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal }
);
if (!response.ok) {
throw new Error("Користувача не знайдено");
}
return response.json();
}, [userId]);
const {
data: user,
error,
loading,
execute: reload,
} = useAsync(loadUser);
if (loading && !user) {
return <p>Завантаження...</p>;
}
if (error && !user) {
return (
<section>
<p>Помилка: {error.message}</p>
<button type="button" onClick={reload}>
Спробувати ще раз
</button>
</section>
);
}
if (!user) {
return null;
}
return (
<section>
{loading && <p>Оновлення даних...</p>}
<h2>{user.name}</h2>
<p>Імʼя користувача: {user.username}</p>
<p>Email: {user.email}</p>
<button type="button" onClick={reload} disabled={loading}>
Оновити
</button>
</section>
);
}
export default UserProfile;useCallbackФункція loadUser залежить від userId. Без useCallback на кожному рендері створювалася б нова функція.
Оскільки useAsync використовує asyncFunction у залежностях useCallback і useEffect, нова функція могла б спричиняти повторний запуск ефекту на кожному рендері.
const loadUser = useCallback(async (signal) => {
// ...
}, [userId]);Тепер функція змінюється лише тоді, коли змінюється userId.
Нижче наведено мінімальний приклад компонента та Hook, які можна використовувати в React-проєкті.
useAsync.jsimport { useCallback, useEffect, useRef, useState } from "react";
export function useAsync(asyncFunction, immediate = true) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const controllerRef = useRef(null);
const requestIdRef = useRef(0);
const execute = useCallback(async () => {
const requestId = ++requestIdRef.current;
// Попередній запит більше не потрібен
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setLoading(true);
setError(null);
try {
const result = await asyncFunction(controller.signal);
// Приймаємо результат лише останнього актуального запиту
if (
requestId === requestIdRef.current &&
!controller.signal.aborted
) {
setData(result);
}
return result;
} catch (caughtError) {
// Скасування запиту не показуємо як помилку
if (caughtError.name === "AbortError") {
return;
}
if (requestId === requestIdRef.current) {
setError(caughtError);
}
throw caughtError;
} finally {
if (
requestId === requestIdRef.current &&
!controller.signal.aborted
) {
setLoading(false);
}
}
}, [asyncFunction]);
useEffect(() => {
if (!immediate) {
return;
}
execute();
return () => {
controllerRef.current?.abort();
};
}, [execute, immediate]);
return {
data,
error,
loading,
execute,
};
}UserProfile.jsximport { useCallback } from "react";
import { useAsync } from "./useAsync";
export default function UserProfile({ userId }) {
const loadUser = useCallback(async (signal) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal }
);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
return response.json();
}, [userId]);
const {
data: user,
error,
loading,
execute: reload,
} = useAsync(loadUser);
if (loading && !user) {
return <p>Завантаження користувача...</p>;
}
if (error && !user) {
return (
<div>
<p>Не вдалося завантажити користувача: {error.message}</p>
<button type="button" onClick={reload}>
Повторити
</button>
</div>
);
}
if (!user) {
return null;
}
return (
<article>
<h1>{user.name}</h1>
<p>Логін: {user.username}</p>
<p>Email: {user.email}</p>
{loading && <p>Оновлення...</p>}
<button type="button" onClick={reload} disabled={loading}>
Оновити дані
</button>
</article>
);
}Іноді асинхронну операцію не потрібно запускати одразу після монтування компонента. Для цього можна передати false другим аргументом:
const { data, error, loading, execute } = useAsync(
async (signal) => {
const response = await fetch("/api/report", { signal });
if (!response.ok) {
throw new Error("Не вдалося завантажити звіт");
}
return response.json();
},
false
);Тепер запит розпочнеться лише після виклику execute:
<button type="button" onClick={execute} disabled={loading}>
Завантажити звіт
</button>У прикладі Hook не очищає data, коли запускається новий запит. Це дає змогу показувати старі дані під час оновлення:
if (loading && user) {
// Старі дані залишаються на екрані,
// а loading показує, що виконується оновлення
}Такий підхід зазвичай зручніший за повне очищення інтерфейсу:
користувач не бачить різкого зникнення контенту;
можна показати індикатор оновлення;
помилку нового запиту можна відобразити, не втрачаючи попередні дані.
У разі помилки компонент може показувати старий результат і повідомлення:
{error && user && (
<p>Не вдалося оновити дані: {error.message}</p>
)}Небезпечно запускати асинхронну операцію без очищення:
useEffect(() => {
fetch("/api/data")
.then((response) => response.json())
.then((result) => {
setData(result);
});
}, []);Якщо компонент демонтується до завершення запиту, результат більше не потрібен. Для цього потрібно скасовувати запит через AbortController або перевіряти актуальність операції.
response.okfetch не вважає HTTP-помилки на кшталт 404 або 500 винятками. Promise буде успішно виконано, тому response.ok потрібно перевіряти вручну:
const response = await fetch("/api/data", { signal });
if (!response.ok) {
throw new Error("Сервер повернув помилку");
}Якщо запустити два запити поспіль, перший може завершитися після другого. Без перевірки актуальності його результат перезапише новіші дані.
Саме тому Hook використовує requestIdRef:
if (requestId === requestIdRef.current) {
setData(result);
}useCallbackЯкщо асинхронна функція використовує userId, query або інше значення з компонента, це значення потрібно додати до залежностей:
const loadUser = useCallback(async (signal) => {
return fetch(`/api/users/${userId}`, { signal });
}, [userId]);Інакше Hook може використовувати застаріле значення.
Скасування запиту не завжди є реальною помилкою. Воно може відбутися під час переходу на іншу сторінку або перед запуском нового запиту.
Тому AbortError обробляється окремо:
if (caughtError.name === "AbortError") {
return;
}Власний Hook для асинхронних даних повинен:
зберігати результат, помилку та стан завантаження;
надавати функцію повторного запуску;
правильно обробляти HTTP-помилки;
скасовувати неактуальні запити;
не оновлювати стан результатом застарілої операції;
враховувати залежності асинхронної функції;
відрізняти скасування запиту від справжньої помилки.
useAsync із такою відповідальністю ізолює складну логіку від компонентів і дає змогу повторно використовувати однакову модель роботи з асинхронними даними в усьому застосунку.