Пошук уроків, статей та іншого контенту
Реалізуєте Optimistic UI, щоб миттєво показувати очікуваний результат дії та коректно обробляти помилки.
Optimistic UI — це підхід, за якого інтерфейс одразу показує очікуваний результат дії, не чекаючи відповіді сервера.
Наприклад, після надсилання коментаря користувач одразу бачить його у списку зі статусом «Надсилання…». Паралельно застосунок виконує запит до сервера:
якщо запит успішний — тимчасовий елемент замінюється даними сервера;
якщо запит завершився помилкою — тимчасовий елемент прибирається, а користувачу показується повідомлення про помилку.
Це покращує сприйняття швидкості застосунку, особливо коли мережевий запит займає сотні мілісекунд або більше.
У сучасному React для цього призначений хук useOptimistic, доступний у React 19.
useOptimisticХук повертає два значення:
const [optimisticState, addOptimistic] = useOptimistic(
state,
updateFunction
);state — актуальний стан із сервера;
optimisticState — стан, який слід показувати в інтерфейсі;
addOptimistic(value) — додає тимчасову зміну;
updateFunction — описує, як застосувати тимчасову зміну до стану.
Функція оновлення має вигляд:
(currentState, optimisticValue) => nextStateReact показує оптимістичний стан, поки асинхронна дія виконується. Коли дія завершується:
після успіху потрібно оновити базовий стан даними сервера;
після помилки базовий стан не змінюється, тому React прибирає оптимістичну зміну.
addOptimistic потрібно викликати всередині Transition або іншої React Action.
Нижче наведено повний приклад компонента. Функція saveComment імітує серверний запит: у 25% випадків він завершується помилкою.
import { StrictMode, useOptimistic, useState, useTransition } from "react";
import { createRoot } from "react-dom/client";
const initialComments = [
{
id: "1",
text: "Optimistic UI робить інтерфейс швидшим.",
author: "Олена",
status: "sent",
},
{
id: "2",
text: "Важливо не забувати про обробку помилок.",
author: "Андрій",
status: "sent",
},
];
// Імітація асинхронного запиту до сервера
function saveComment(text) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.25) {
reject(new Error("Сервер тимчасово недоступний"));
return;
}
resolve({
id: crypto.randomUUID(),
text,
author: "Ви",
status: "sent",
});
}, 1200);
});
}
function Comments() {
const [comments, setComments] = useState(initialComments);
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const [isPending, startTransition] = useTransition();
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(currentComments, newComment) => [
...currentComments,
{
...newComment,
status: "sending",
},
]
);
function handleSubmit(event) {
event.preventDefault();
const text = draft.trim();
if (!text || isPending) {
return;
}
const optimisticComment = {
id: `temporary-${crypto.randomUUID()}`,
text,
author: "Ви",
};
setError("");
setDraft("");
startTransition(async () => {
// Коментар одразу з'являється в інтерфейсі
addOptimisticComment(optimisticComment);
try {
const savedComment = await saveComment(text);
// Базовий стан оновлюється лише даними, підтвердженими сервером
setComments((currentComments) => [
...currentComments,
savedComment,
]);
} catch (requestError) {
// Оптимістична зміна автоматично зникне після завершення Action
setDraft(text);
setError(requestError.message);
}
});
}
return (
<main>
<h1>Коментарі</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="comment">Новий коментар</label>
<textarea
id="comment"
value={draft}
onChange={(event) => setDraft(event.target.value)}
disabled={isPending}
rows={4}
placeholder="Напишіть коментар"
/>
<button type="submit" disabled={isPending || !draft.trim()}>
{isPending ? "Надсилання…" : "Надіслати"}
</button>
</form>
{error && (
<p role="alert">
{error}. Текст коментаря відновлено — спробуйте ще раз.
</p>
)}
<ul aria-live="polite">
{optimisticComments.map((comment) => (
<li key={comment.id}>
<strong>{comment.author}:</strong> {comment.text}{" "}
{comment.status === "sending" && (
<small>(надсилання…)</small>
)}
</li>
))}
</ul>
</main>
);
}
function App() {
return <Comments />;
}
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);У компоненті є два представлення списку:
const [comments, setComments] = useState(initialComments);comments — це базовий стан. Він містить лише коментарі, які вже підтвердив сервер.
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(currentComments, newComment) => [
...currentComments,
{
...newComment,
status: "sending",
},
]
);optimisticComments використовується для рендерингу. Він може тимчасово містити коментар, якого ще немає в comments.
Важливо: інтерфейс повинен читати саме optimisticComments, а не comments.
До відповіді сервера новий коментар не має справжнього ідентифікатора. Тому створюється тимчасовий:
const optimisticComment = {
id: `temporary-${crypto.randomUUID()}`,
text,
author: "Ви",
};Ідентифікатор потрібен для атрибута key у списку. Після успішної відповіді сервер повертає власний id, і базовий стан отримує вже підтверджений об’єкт.
startTransition(async () => {
addOptimisticComment(optimisticComment);
try {
const savedComment = await saveComment(text);
setComments((currentComments) => [
...currentComments,
savedComment,
]);
} catch (requestError) {
setError(requestError.message);
}
});Послідовність дій така:
startTransition запускає асинхронну React Action.
addOptimisticComment одразу додає коментар до оптимістичного стану.
React показує коментар зі статусом sending.
Виконується запит до сервера.
Після успіху setComments додає підтверджений об’єкт до базового стану.
Після помилки базовий стан залишається незмінним, тому оптимістичний коментар зникає.
useTransition повертає isPending:
const [isPending, startTransition] = useTransition();Цей прапорець використовується для:
зміни тексту кнопки;
блокування повторного надсилання;
блокування поля введення під час поточного запиту.
<button type="submit" disabled={isPending || !draft.trim()}>
{isPending ? "Надсилання…" : "Надіслати"}
</button>Оптимістичний інтерфейс не означає, що потрібно приховувати факт виконання запиту. Користувач має бачити, що дія ще не підтверджена сервером.
Оптимістична зміна не повинна ставати остаточним станом самостійно.
Після успішного запиту потрібно використати відповідь сервера:
const savedComment = await saveComment(text);
setComments((currentComments) => [
...currentComments,
savedComment,
]);Це важливо, тому що сервер може:
призначити справжній ідентифікатор;
нормалізувати текст;
додати час створення;
додати дані автора;
змінити або відхилити частину переданих даних.
У разі помилки не потрібно вручну видаляти оптимістичний елемент із comments. Його там ще немає. Достатньо не змінювати базовий стан і показати повідомлення користувачу.
Оптимістичний підхід добре працює для дій, результат яких легко передбачити:
додавання коментаря;
перемикання вподобання;
зміна статусу завдання;
додавання товару до кошика;
редагування локально відображеного тексту.
Обережніше його слід використовувати для дій, результат яких може суттєво відрізнятися від очікуваного:
фінансові операції;
бронювання обмеженого ресурсу;
зміни прав доступу;
дії з важкими або незворотними наслідками.
У таких випадках інтерфейс може показувати очікуваний результат, але повинен чітко позначати його як непідтверджений і коректно обробляти відмову сервера.
Повідомлення про помилку має пояснювати:
що саме не вдалося;
чи було скасовано оптимістичну зміну;
що користувач може зробити далі.
У прикладі текст коментаря зберігається:
catch (requestError) {
setDraft(text);
setError(requestError.message);
}Завдяки цьому користувачу не потрібно вводити повідомлення повторно.
Для складніших дій можна додатково зберігати:
тип помилки;
кількість спроб;
тимчасовий ідентифікатор операції;
можливість повторити саме невдалу дію.
Неправильно:
comments.map(renderComment);У такому разі тимчасовий елемент не відображатиметься.
Потрібно рендерити стан, який повертає useOptimistic:
optimisticComments.map(renderComment);Не слід додавати коментар у базовий стан до завершення запиту:
setComments((currentComments) => [
...currentComments,
optimisticComment,
]);
await saveComment(text);Якщо запит завершиться помилкою, цей коментар уже залишиться в базовому стані, і автоматичний rollback не спрацює.
Не використовуйте один і той самий key для всіх оптимістичних елементів. Кожна тимчасова операція повинна мати унікальний ідентифікатор.
Optimistic UI не замінює обробку помилок. Кожна асинхронна дія повинна мати сценарій відмови:
оптимістичні дані прибираються;
показується повідомлення;
за можливості зберігаються введені користувачем дані.
Не варто без необхідності записувати оптимістичні значення в основний стан. Базовий стан має відображати підтверджені сервером дані, а useOptimistic — тимчасове представлення під час виконання дії.
Optimistic UI одразу показує очікуваний результат дії.
У React 19 для цього використовується useOptimistic.
useOptimistic працює поверх базового стану та автоматично повертається до нього після завершення дії.
Після успіху базовий стан потрібно оновити відповіддю сервера.
Після помилки базовий стан не змінюється, тому оптимістична зміна скасовується.
useTransition допомагає позначити асинхронну дію та показати стан очікування.
Надійний Optimistic UI завжди має обробляти помилки, тимчасові ідентифікатори та можливість повторити дію.