Пошук уроків, статей та іншого контенту
Додасте кешування відповідей, інвалідацію та повторне використання даних для зменшення кількості запитів.
Без кешування кожен монтування компонента або повторний перехід на сторінку може створювати новий HTTP-запит. Це збільшує:
навантаження на сервер;
час очікування відповіді;
кількість однакових запитів;
витрати трафіку.
Кеш зберігає вже отриману відповідь на певний час. Якщо компонент повторно запитує ті самі дані, застосунок може використати кеш замість нового запиту.
Для кожного запису кешу потрібно визначити:
Ключ — за яким запит можна знайти відповідь.
Дані — збережена відповідь.
Час життя — як довго дані вважаються актуальними.
Момент інвалідації — коли кеш потрібно видалити примусово.
У React кеш можна зберігати поза компонентами, наприклад у модулі. Тоді кілька екземплярів компонентів зможуть використовувати одні й ті самі дані.
Для кешу можна використати Map:
const cache = new Map();
cache.set("/api/users", {
data: [{ id: 1, name: "Anna" }],
expiresAt: Date.now() + 30_000
});
const entry = cache.get("/api/users");
if (entry && entry.expiresAt > Date.now()) {
console.log(entry.data);
}Кеш у пам’яті:
існує лише поки працює вкладка;
очищається після перезавантаження сторінки;
підходить для повторного використання даних протягом поточної сесії;
не є постійним сховищем.
Розглянемо хук useCachedJson, який:
виконує GET-запит;
кешує успішну відповідь;
повторно використовує свіжі дані;
не створює кілька однакових паралельних запитів;
підтримує ручну інвалідацію;
дозволяє примусово оновити дані.
import { useCallback, useEffect, useState } from "react";
// Кеш успішних відповідей
const responseCache = new Map();
// Запити, які вже виконуються
const pendingRequests = new Map();
function getCachedValue(key) {
const entry = responseCache.get(key);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
responseCache.delete(key);
return null;
}
return entry.data;
}
function invalidateCache(key) {
if (key) {
responseCache.delete(key);
} else {
responseCache.clear();
}
}
async function fetchJson(key, ttl) {
const cachedValue = getCachedValue(key);
if (cachedValue !== null) {
return cachedValue;
}
// Якщо такий запит уже виконується, використовуємо його Promise
const existingRequest = pendingRequests.get(key);
if (existingRequest) {
return existingRequest;
}
const request = fetch(key)
.then((response) => {
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
return response.json();
})
.then((data) => {
responseCache.set(key, {
data,
expiresAt: Date.now() + ttl
});
return data;
})
.finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, request);
return request;
}
export function useCachedJson(url, options = {}) {
const {
ttl = 30_000
} = options;
const [reloadVersion, setReloadVersion] = useState(0);
const [state, setState] = useState(() => {
const cachedValue = getCachedValue(url);
return {
data: cachedValue,
loading: cachedValue === null,
error: null
};
});
useEffect(() => {
let isCurrent = true;
const cachedValue = getCachedValue(url);
if (cachedValue !== null) {
setState({
data: cachedValue,
loading: false,
error: null
});
return () => {
isCurrent = false;
};
}
setState((previousState) => ({
...previousState,
loading: true,
error: null
}));
fetchJson(url, ttl)
.then((data) => {
if (!isCurrent) {
return;
}
setState({
data,
loading: false,
error: null
});
})
.catch((error) => {
if (!isCurrent) {
return;
}
setState({
data: null,
loading: false,
error
});
});
return () => {
isCurrent = false;
};
}, [url, ttl, reloadVersion]);
const refresh = useCallback(() => {
invalidateCache(url);
setReloadVersion((version) => version + 1);
}, [url]);
return {
...state,
refresh
};
}
function TodoList() {
const {
data: todos,
loading,
error,
refresh
} = useCachedJson(
"https://jsonplaceholder.typicode.com/todos?_limit=5",
{ ttl: 60_000 }
);
if (loading && !todos) {
return <p>Завантаження...</p>;
}
if (error) {
return (
<div>
<p>Не вдалося завантажити завдання.</p>
<button onClick={refresh}>Повторити</button>
</div>
);
}
return (
<section>
<button onClick={refresh}>
Оновити дані
</button>
{loading && <p>Оновлення...</p>}
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title}
</li>
))}
</ul>
</section>
);
}
export default TodoList;У цьому прикладі responseCache і pendingRequests створюються один раз на рівні модуля. Вони не належать окремому екземпляру TodoList.
Параметр ttl задається в мілісекундах:
useCachedJson("/api/products", {
ttl: 5 * 60 * 1000
});У цьому випадку відповідь вважається свіжою протягом п’яти хвилин.
Якщо час минув:
запис видаляється з кешу;
виконується новий запит;
нова відповідь зберігається в кеші.
Значення TTL залежить від характеру даних:
список категорій може кешуватися довго;
список повідомлень має короткий TTL;
дані, які змінюються після кожної дії користувача, краще інвалідувати одразу після зміни.
Оскільки кеш знаходиться поза компонентом, два компоненти з однаковим ключем використовують одну відповідь:
function Header() {
const { data } = useCachedJson("/api/profile");
return <p>{data?.name}</p>;
}
function ProfilePage() {
const { data } = useCachedJson("/api/profile");
return <h1>{data?.name}</h1>;
}Якщо Header уже отримав /api/profile, ProfilePage зможе взяти дані з кешу без нового запиту.
Кешування саме по собі не завжди захищає від дублювання. Якщо два компоненти монтуються майже одночасно, обидва можуть перевірити кеш до того, як перший запит завершиться.
Для цього використовується pendingRequests:
const pendingRequests = new Map();Коли запит уже виконується, інші споживачі отримують той самий Promise:
const existingRequest = pendingRequests.get(key);
if (existingRequest) {
return existingRequest;
}У результаті виконується один HTTP-запит, а його відповідь використовується кількома компонентами.
Інвалідація — це примусове видалення даних із кешу. Вона потрібна, коли кешована відповідь більше не відповідає стану сервера.
Наприклад, після створення нового завдання:
async function createTodo(title) {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
title,
completed: false,
userId: 1
})
}
);
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
const newTodo = await response.json();
// Після успішної зміни потрібно видалити застарілий список
invalidateCache(
"https://jsonplaceholder.typicode.com/todos?_limit=5"
);
return newTodo;
}Після інвалідації наступний виклик useCachedJson виконає новий запит.
У хуку з попереднього прикладу інвалідація прихована всередині функції refresh:
const { refresh } = useCachedJson("/api/todos");
await createTodo("Вивчити кешування");
refresh();Порядок дій важливий:
виконати операцію зміни на сервері;
дочекатися успішної відповіді;
інвалідовувати пов’язаний кеш;
завантажити актуальні дані.
Не слід видаляти кеш до успішного завершення мутації. Якщо запит завершиться помилкою, старі дані все ще можуть бути корисними для користувача.
Ключ має однозначно описувати запит. Для запитів із параметрами потрібно включати параметри в ключ:
useCachedJson(`/api/products?category=${category}&page=${page}`);Такі запити повинні мати різні записи кешу:
/api/products?category=books&page=1
/api/products?category=books&page=2Якщо використати лише /api/products, дані однієї сторінки можуть помилково відобразитися на іншій.
Ключ також має враховувати всі значення, які впливають на відповідь:
ідентифікатор користувача;
номер сторінки;
фільтри;
параметри сортування;
мову відповіді.
Після інвалідації можна показувати індикатор завантаження. Якщо старі дані вже є, зручно не очищати їх одразу, а показати їх разом із позначкою оновлення:
function ProductList() {
const { data, loading, error, refresh } = useCachedJson("/api/products");
if (!data && loading) {
return <p>Завантаження списку...</p>;
}
if (error && !data) {
return <p>Помилка завантаження.</p>;
}
return (
<div>
<button onClick={refresh}>Оновити</button>
{loading && <p>Оновлення списку...</p>}
<ul>
{data?.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}Такий підхід називають збереженням попередніх даних під час завантаження. Користувач бачить вміст сторінки, а не порожній екран після кожного оновлення.
Коли URL або його параметри змінюються, React повторно запускає ефект:
function SearchResults({ query }) {
const url = `/api/search?q=${encodeURIComponent(query)}`;
const { data, loading } = useCachedJson(url);
if (loading && !data) {
return <p>Пошук...</p>;
}
return (
<ul>
{data?.items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}Для кожного значення query створюється окремий ключ. Якщо користувач повернеться до попереднього запиту, його відповідь може бути взята з кешу, якщо TTL ще не завершився.
Неправильно:
useCachedJson("/api/products");Якщо відповідь залежить від фільтра, сторінки або користувача, ці значення потрібно додати до ключа.
Якщо створити, змінити або видалити запис, старий список у кеші може залишитися актуальним лише візуально. Після успішної мутації потрібно інвалідовувати пов’язані записи.
Довгий час життя кешу зменшує кількість запитів, але збільшує ризик показати застарілі дані. TTL потрібно вибирати відповідно до того, як часто змінюється інформація.
Кешувати помилку мережі зазвичай не потрібно. Тимчасова помилка не означає, що наступний запит також буде невдалим. У кеш варто записувати лише успішні відповіді.
Якщо перед запитом встановлювати data: null, користувач бачитиме порожній екран. Краще зберігати попередні дані й окремо показувати стан loading.
Запит може завершитися після того, як компонент уже розмонтували або змінили його ключ. Прапорець isCurrent у хуку не дозволяє оновлювати стан неактивного екземпляра.
Кеш зменшує кількість однакових HTTP-запитів.
Для кешу потрібно визначити ключ, дані та час життя.
Кеш на рівні модуля можна спільно використовувати між компонентами.
pendingRequests допомагає об’єднати однакові паралельні запити.
Після успішної мутації пов’язані дані потрібно інвалідовувати.
Ключ повинен містити всі параметри, що впливають на відповідь.
Під час оновлення можна зберігати старі дані й показувати індикатор завантаження.
Помилки не варто зберігати в кеші як звичайні успішні відповіді.