Пошук уроків, статей та іншого контенту
Навчитеся миттєво оновлювати інтерфейс до відповіді сервера та відновлювати стан у разі помилки.
Optimistic UI — це підхід, за якого інтерфейс оновлюється одразу після дії користувача, не очікуючи відповіді сервера.
Наприклад, користувач натискає кнопку «Подобається»:
Інтерфейс одразу змінює кнопку та збільшує лічильник.
Паралельно застосунок надсилає запит на сервер.
Якщо сервер відповів успішно, новий стан залишається.
Якщо сталася помилка, застосунок повертає попередній стан і повідомляє користувача.
Без Optimistic UI користувач бачить затримку між натисканням кнопки та зміною інтерфейсу.
Для оптимістичного оновлення потрібно:
Зберегти попередній стан.
Негайно встановити очікуваний новий стан.
Надіслати запит на сервер.
У разі успіху підтвердити або замінити стан відповіддю сервера.
У разі помилки відновити збережений попередній стан.
Схематично це виглядає так:
попередній стан
↓
оптимістичне оновлення UI
↓
запит на сервер
↙ ↘
успіх помилка
↓ ↓
підтвердити відновити станСтворимо компонент, який:
одразу змінює статус Like;
одразу змінює кількість вподобань;
імітує запит до сервера;
у випадку помилки виконує rollback — повернення до попереднього стану.
import { useState } from "react";
// Імітація запиту до сервера
function toggleLikeOnServer(nextLiked) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const requestSucceeded = Math.random() > 0.3;
if (!requestSucceeded) {
reject(new Error("Не вдалося зберегти вподобання"));
return;
}
// Сервер повертає фактичний стан після обробки запиту
resolve({
liked: nextLiked,
likes: nextLiked ? 43 : 42,
});
}, 800);
});
}
export default function LikeButton() {
const [likeState, setLikeState] = useState({
liked: false,
likes: 42,
});
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState("");
async function handleLike() {
if (isPending) {
return;
}
setError("");
// Зберігаємо стан до оптимістичного оновлення
const previousState = likeState;
const nextLiked = !likeState.liked;
// Оновлюємо інтерфейс одразу
setLikeState({
liked: nextLiked,
likes: nextLiked
? likeState.likes + 1
: likeState.likes - 1,
});
setIsPending(true);
try {
const serverState = await toggleLikeOnServer(nextLiked);
// Сервер є джерелом остаточного стану
setLikeState(serverState);
} catch (requestError) {
// Якщо запит не вдався, повертаємо попередній стан
setLikeState(previousState);
setError(requestError.message);
} finally {
setIsPending(false);
}
}
return (
<div>
<button
type="button"
onClick={handleLike}
disabled={isPending}
aria-pressed={likeState.liked}
>
{likeState.liked ? "♥ Подобається" : "♡ Подобається"}{" "}
({likeState.likes})
</button>
{isPending && <p>Зберігаємо зміни...</p>}
{error && (
<p role="alert">
{error}. Спробуйте ще раз.
</p>
)}
</div>
);
}Поки триває запит, кнопка вимкнена через disabled={isPending}. Це запобігає кільком паралельним запитам і спрощує відновлення стану.
Розглянемо ключові частини обробника:
const previousState = likeState;Зберігається стан до зміни. Він потрібен, якщо сервер поверне помилку.
setLikeState({
liked: nextLiked,
likes: nextLiked
? likeState.likes + 1
: likeState.likes - 1,
});Стан змінюється одразу, тому користувач не чекає завершення запиту.
try {
const serverState = await toggleLikeOnServer(nextLiked);
setLikeState(serverState);
} catch (requestError) {
setLikeState(previousState);
}Якщо запит успішний, компонент використовує відповідь сервера. Якщо запит завершився помилкою, використовується збережений стан.
fetchУ справжньому застосунку замість імітації можна використовувати fetch:
async function toggleLikeOnServer(postId, nextLiked) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: nextLiked ? "POST" : "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Сервер не зміг зберегти вподобання");
}
return response.json();
}Обробник компонента при цьому залишається таким самим:
try {
const serverState = await toggleLikeOnServer(postId, nextLiked);
setLikeState(serverState);
} catch (error) {
setLikeState(previousState);
setError(error.message);
}Важливо перевіряти response.ok. fetch не вважає HTTP-помилки на кшталт 400 або 500 винятком автоматично. Тому помилку потрібно створити вручну.
Той самий підхід можна застосовувати до списків. Наприклад, під час видалення елемента:
Зберегти попередній список.
Прибрати елемент з інтерфейсу одразу.
Надіслати запит на видалення.
У разі помилки повернути попередній список.
async function handleDelete(id) {
const previousItems = items;
setItems((currentItems) =>
currentItems.filter((item) => item.id !== id)
);
try {
await deleteItemOnServer(id);
} catch (error) {
// Повертаємо елемент, якщо сервер не підтвердив видалення
setItems(previousItems);
setError("Не вдалося видалити елемент");
}
}Для такого сценарію бажано показувати повідомлення про помилку або тимчасовий індикатор, щоб користувач розумів, чому елемент знову з’явився.
Оптимістичне оновлення добре підходить для дій, які:
мають зрозумілий очікуваний результат;
часто виконуються користувачем;
повинні відчуватися миттєвими;
можуть бути безпечно скасовані у випадку помилки.
Типові приклади:
Like та Unlike;
додавання або видалення закладки;
перемикання статусу задачі;
зміна перемикача;
видалення елемента зі списку.
Для операцій, де результат складно передбачити або помилка має серйозні наслідки, краще спочатку дочекатися відповіді сервера.
Окрім основних даних, компоненту зазвичай потрібні додаткові стани:
isPending — запит виконується;
error — запит завершився помилкою;
основний стан даних — поточне значення в інтерфейсі.
Наприклад:
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState("");Це дає змогу:
заблокувати повторну дію;
показати індикатор виконання;
повідомити користувача про rollback;
не плутати оптимістичний стан із підтвердженим сервером станом.
Якщо не зберегти попередній стан, після помилки не буде чим виконати rollback.
Неправильно:
setLiked(true);
try {
await saveLike();
} catch {
// Немає попереднього значення для відновлення
}Правильно:
const previousLiked = liked;
setLiked(true);
try {
await saveLike();
} catch {
setLiked(previousLiked);
}Оптимістичне значення є лише припущенням. Сервер може повернути іншу кількість, статус або нормалізовані дані.
Тому після успішного запиту варто використовувати відповідь сервера:
const serverState = await saveLike();
setLikeState(serverState);Кілька паралельних запитів можуть завершитися в іншому порядку. У результаті старіша відповідь може перезаписати новішу.
Для простих дій можна тимчасово блокувати кнопку:
<button disabled={isPending} onClick={handleLike}>
Подобається
</button>Якщо інтерфейс повернувся до попереднього стану без пояснення, користувач може сприйняти це як помилку інтерфейсу. Після невдалого запиту показуйте зрозуміле повідомлення:
setError("Зміни не збережено. Спробуйте ще раз.");Під час оновлення списків або інших колекцій використовуйте функціональну форму setState:
setItems((currentItems) =>
currentItems.filter((item) => item.id !== id)
);Так React використає найактуальніше значення стану.
Optimistic UI змінює інтерфейс до отримання відповіді сервера.
Перед оновленням потрібно зберегти попередній стан.
У разі успіху можна залишити оптимістичний стан або замінити його відповіддю сервера.
У разі помилки потрібно виконати rollback.
Поки запит виконується, часто варто блокувати повторну дію.
Користувачу потрібно показувати помилки та пояснювати повернення стану.