Axios та React: Професійна Архітектура Запитів
Axios та React: Професійна Архітектура Запитів
Уявіть, що ви будуєте панель керування для великого інтернет-магазину. Вам потрібно завантажувати товари, оновлювати ціни в реальному часі, обробляти помилки мережі та скасовувати зайві запити, коли користувач швидко перемикається між вкладками.
У "чистому" React ви швидко зіткнетеся з проблемою: логіка запитів починає змішуватися з UI-компонентами. Ваш useEffect розростається до 50 рядків, обробка помилок дублюється, а токени авторизації доводиться передавати вручну в кожен запит.
Сьогодні ми розберемо, як інтегрувати Axios у React 19 так, щоб ваш код був чистим, масштабованим та стійким до помилок. Ми не просто "зробимо запит", ми побудуємо професійний шар роботи з даними.
Чому саме Axios в екосистемі React?
Хоча React не має "офіційного" способу робити запити, спільнота виробила певні стандарти. Чому ж більшість обирає Axios замість вбудованого fetch?
| Проблема Fetch в React | Рішення Axios |
|---|---|
Boilerplate: Потрібно постійно писати if (!res.ok) throw ... та await res.json() | Лаконічність: Автоматична валідація статусів та парсинг JSON зменшують код в useEffect. |
| Race Conditions: Складно скасовувати запити при розмонтуванні компонента | AbortController: Проста інтеграція з useEffect cleanup функцією. |
| Дублювання конфігу: Передача заголовків (Auth) у кожен виклик | Interceptors: Глобальне налаштування заголовків та обробки помилок в одному місці. |
Анатомія запиту в React компоненті
Перш ніж будувати складні абстракції, розберемо "атомарний" рівень — як зробити запит правильно, враховуючи життєвий цикл React компонента.
Анти-патерн: Запит "в лоб"
Початківці часто роблять цю помилку — викликають запит прямо в тілі компонента або обробнику подій без врахування стану.
// ❌ НЕ РОБІТЬ ТАК
function BadComponent() {
const data = axios.get('/api/data'); // 😱 Запит буде виконуватись при кожному рендері!
return <div>{data.toString()}</div>;
}
Патерн: "Fetch-on-Mount" з useEffect
Класичний підхід для отримання даних при завантаженні компонента.
1. Підготовка станів
Нам потрібно три стани для повного контролю UI: data (дані), loading (індикатор завантаження) та error (повідомлення про помилку).
2. Ефект з очищенням
Використовуємо useEffect з порожнім масивом залежностей [] для виконання коду один раз при монтуванні.
3. Обробка скасування
Критично важливо скасовувати запит, якщо компонент був видалений зі сторінки до завершення завантаження (наприклад, користувач швидко перейшов на іншу сторінку).
import { useState, useEffect } from 'react';
import axios from 'axios';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 1. Створюємо контролер для скасування
const controller = new AbortController();
const fetchUser = async () => {
try {
setIsLoading(true);
// 2. Передаємо сигнал в Axios
const response = await axios.get(`https://api.escuelajs.co/api/v1/users/${userId}`, {
signal: controller.signal
});
setUser(response.data);
setError(null);
} catch (err) {
// 3. Ігноруємо помилку, якщо це скасування запиту
if (axios.isCancel(err)) {
console.log('Request canceled', err.message);
} else {
setError(err.message);
}
} finally {
// 4. Оновлюємо стан завантаження, тільки якщо запит не був скасований
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
};
fetchUser();
// 5. Функція очищення (Cleanup function)
return () => {
controller.abort();
};
}, [userId]); // Перезапускаємо ефект, якщо змінився userId
if (isLoading) return <div className="spinner">Завантаження...</div>;
if (error) return <div className="error">Помилка: {error}</div>;
if (!user) return null;
return (
<div className="card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Розбір коду:
AbortController: Це стандартний Web API. Якщо компонент демонтується (return зuseEffect), ми викликаємоcontroller.abort(). Axios бачить це і перериває HTTP-з'єднання.axios.isCancel(err): Дозволяє відрізнити "поганий" запит (404, 500) від "скасованого" (користувач пішов зі сторінки), щоб не показувати помилку даремно.- Залежність
[userId]: Якщо ID зміниться, старий запит скасується, і почнеться новий.
Архітектура: Виносимо логіку в Сервісний Шар
Писати axios.get прямо в компонентах — це шлях до дублювання коду. У професійних проєктах ми створюємо API Layer.
1. Налаштування інстансу (Singleton)
Створимо файл конфігурації, де живе "душа" нашої взаємодії з сервером.
import axios from 'axios';
// Створюємо єдиний екземпляр для всього додатку
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://api.escuelajs.co/api/v1',
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 секунд
});
// Interceptor для додавання токена (якщо є)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Interceptor для глобальної обробки помилок
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Логіка для розлогіну користувача
console.warn('Unauthorized! Redirecting to login...');
// window.location.href = '/login';
}
return Promise.reject(error);
}
);
2. Сервіси сутностей (Repository Pattern)
Групуємо методи за бізнес-сутністю. Компоненти не повинні знати про URL-адреси.
import { apiClient } from './axios.client';
export const ProductsService = {
// Отримати всі продукти
getAll: async (params, signal) => {
const { data } = await apiClient.get('/products', { params, signal });
return data;
},
// Отримати один за ID
getById: async (id, signal) => {
const { data } = await apiClient.get(`/products/${id}`, { signal });
return data;
},
// Створити продукт
create: async (productData) => {
const { data } = await apiClient.post('/products', productData);
return data;
}
};
Custom Hooks: React Way
Тепер ми можемо створити власний хук, який інкапсулює логіку useEffect, станів та сервісів. Це зробить наші компоненти неймовірно чистими.
Створення useFetch хука
Цей хук буде універсальним солдатом для GET-запитів.
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
export function useFetch(fetchFn, dependencies = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Зберігаємо функцію у ref, щоб уникнути зайвих ререндерів
const fetchFnRef = useRef(fetchFn);
useEffect(() => {
fetchFnRef.current = fetchFn;
}, [fetchFn]);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// Передаємо signal у функцію запиту
const result = await fetchFnRef.current(controller.signal);
setData(result);
} catch (err) {
if (!axios.isCancel(err)) {
setError(err.response?.data?.message || err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
fetchData();
return () => {
controller.abort();
};
}, dependencies); // Залежить від переданих параметрів (наприклад, [categoryId])
return { data, loading, error };
}
Використання хука в компоненті
Дивіться, наскільки чистішим став наш компонент!
import { useFetch } from '../hooks/useFetch';
import { ProductsService } from '../api/products.service';
export function ProductList({ categoryId }) {
// Хук бере на себе всю складність життєвого циклу
// Важливо: обгортаємо виклик сервісу в стрілочну функцію, щоб передати signal
const { data: products, loading, error } = useFetch(
(signal) => ProductsService.getAll({ categoryId }, signal),
[categoryId] // Перезавантажити, якщо змінилась категорія
);
if (loading) return <div className="skeleton">Loading...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
React 19 та Майбутнє (Suspense & use)
В React 19 з'явився новий хук use, який дозволяє читати проміси прямо в рендері. Це змінює правила гри, наближаючи клієнтський код до серверного.
Як це працює з Axios?
Axios повертає Promise, тому ми можемо використовувати його з use. Але є критична умова: проміс повинен бути створений за межами рендеру або кешований, інакше ви отримаєте нескінченний цикл запитів.
import { use, Suspense } from 'react';
import { apiClient } from '../api/axios.client';
// 1. Створення ресурсу (простий кеш або запит на рівні модуля)
// В реальності використовуйте бібліотеки типу TanStack Query
const productsPromise = apiClient.get('/products').then(res => res.data);
function ProductList() {
// 2. Використання `use` для "розгортання" промісу
// Це призупинить (suspend) компонент, доки дані не завантажаться
const products = use(productsPromise);
return (
<ul>
{products.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
export function Page() {
return (
// 3. Suspense обробляє стан завантаження
<Suspense fallback={<div>Loading products...</div>}>
<ProductList />
</Suspense>
);
}
use: Не створюйте проміси (axios.get(...)) прямо в тілі компонента перед передачею в use(). Це призведе до створення нового запиту при кожному рендері!Оптимістичні оновлення (Optimistic UI)
Коли користувач натискає "Лайк", він не хоче чекати 200мс відповіді сервера. Ми оновлюємо UI миттєво, а запит шлемо у фоні.
import { useState } from 'react';
import { apiClient } from '../api/axios.client';
export function LikeButton({ productId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const handleLike = async () => {
// 1. Оптимістичне оновлення (миттєве)
const previousLikes = likes;
const previousIsLiked = isLiked;
setLikes(prev => isLiked ? prev - 1 : prev + 1);
setIsLiked(!isLiked);
try {
// 2. Реальний запит у фоні
await apiClient.post(`/products/${productId}/like`);
} catch (error) {
// 3. Відкат (Rollback) при помилці
console.error('Like failed', error);
setLikes(previousLikes);
setIsLiked(previousIsLiked);
alert('Не вдалося поставити лайк :(');
}
};
return (
<button onClick={handleLike} className={isLiked ? 'liked' : ''}>
❤️ {likes}
</button>
);
}
Best Practices Checklist
Перевірте свій код на відповідність цим стандартам:
- API Instance: Чи використовуєте ви
axios.createзамість глобального імпорту? - Cleanup: Чи використовуєте ви
AbortControllerуuseEffect? - Environment Variables: Чи винесено
baseURLу змінні оточення (.env)? - Loading States: Чи показуєте ви користувачу, що щось відбувається?
- Error Boundaries: Чи не "падає" весь додаток від однієї помилки запиту?
- Service Layer: Чи відділена логіка запитів від UI компонентів?
Практичне завдання: Побудова Search Bar
Спробуйте реалізувати компонент пошуку товарів, який:
- Використовує Debounce (чекає 500мс після завершення вводу перед запитом).
- Скасовує попередній запит, якщо користувач продовжив вводити.
- Відображає повідомлення "Нічого не знайдено".
Підказка (Рішення)
import { useState, useEffect } from 'react';
import axios from 'axios';
// Простий хук debounce
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
export function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
const controller = new AbortController();
const search = async () => {
setLoading(true);
try {
const { data } = await axios.get(`https://api.escuelajs.co/api/v1/products`, {
params: { title: debouncedQuery },
signal: controller.signal
});
setResults(data);
} catch (err) {
if (!axios.isCancel(err)) console.error(err);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
search();
return () => controller.abort();
}, [debouncedQuery]);
return (
<div className="search-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Пошук..."
className="input"
/>
{loading && <p>Шукаємо...</p>}
<ul>
{results.map(p => <li key={p.id}>{p.title} - ${p.price}</li>)}
</ul>
</div>
);
}