Пошук уроків, статей та іншого контенту
Побудуєте миттєве оновлення інтерфейсу до завершення запиту та передбачите відкат після помилки.
Під час мутації клієнт зазвичай очікує завершення HTTP-запиту:
користувач натискає кнопку;
клієнт надсилає запит;
сервер обробляє мутацію;
клієнт отримує відповідь;
інтерфейс оновлюється.
Навіть якщо запит триває лише кількасот мілісекунд, інтерфейс може здаватися повільним. Оптимістичний інтерфейс змінює локальний стан до завершення запиту, припускаючи, що операція завершиться успішно.
Якщо сервер підтвердив операцію, тимчасові дані замінюються даними з відповіді. Якщо сервер повернув помилку, локальна зміна скасовується.
Типовий життєвий цикл оптимістичної мутації:
зберегти дані, потрібні для відкату;
негайно застосувати локальну зміну;
надіслати запит;
синхронізувати стан із відповіддю сервера;
у разі помилки виконати відкат.
Створимо сторінку зі списком завдань. Після надсилання форми нове завдання одразу з’являється у списку зі статусом «збереження».
Для демонстрації сервер навмисно повертатиме помилку, якщо назва завдання містить слово помилка.
У реальному застосунку замість масиву в пам’яті використовувалася б база даних. Серверний маршрут потрібен тут лише для того, щоб показати повний цикл мутації.
Файл app/api/tasks/route.js:
let tasks = [
{ id: 1, title: "Перевірити оптимістичне оновлення" },
{ id: 2, title: "Додати обробку помилок" },
];
let nextId = 3;
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json(tasks);
}
export async function POST(request) {
const body = await request.json();
const title = typeof body.title === "string" ? body.title.trim() : "";
await new Promise((resolve) => setTimeout(resolve, 800));
if (!title) {
return Response.json(
{ error: "Назва завдання не може бути порожньою" },
{ status: 400 },
);
}
if (title.toLowerCase().includes("помилка")) {
return Response.json(
{ error: "Сервер відхилив це завдання" },
{ status: 400 },
);
}
const task = {
id: nextId,
title,
};
nextId += 1;
tasks.push(task);
return Response.json(task, { status: 201 });
}Затримка в 800 мілісекунд дає змогу побачити різницю між оптимістичним оновленням і оновленням після відповіді сервера.
Файл app/page.jsx:
"use client";
import { useEffect, useState } from "react";
export default function TasksPage() {
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
async function loadTasks() {
try {
const response = await fetch("/api/tasks");
if (!response.ok) {
throw new Error("Не вдалося завантажити завдання");
}
const data = await response.json();
if (!ignore) {
setTasks(data);
}
} catch (requestError) {
if (!ignore) {
setError(requestError.message);
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
loadTasks();
return () => {
ignore = true;
};
}, []);
async function handleSubmit(event) {
event.preventDefault();
const taskTitle = title.trim();
if (!taskTitle || isSubmitting) {
return;
}
setError("");
const temporaryId = `temporary-${crypto.randomUUID()}`;
const optimisticTask = {
id: temporaryId,
title: taskTitle,
isPending: true,
};
// Крок 1: одразу показуємо завдання користувачу
setTasks((currentTasks) => [...currentTasks, optimisticTask]);
setTitle("");
setIsSubmitting(true);
try {
const response = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: taskTitle }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "Не вдалося зберегти завдання");
}
// Крок 2: замінюємо тимчасовий об'єкт відповіддю сервера
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === temporaryId
? {
...payload,
isPending: false,
}
: task,
),
);
} catch (requestError) {
// Крок 3: видаляємо оптимістичний об'єкт після помилки
setTasks((currentTasks) =>
currentTasks.filter((task) => task.id !== temporaryId),
);
setTitle(taskTitle);
setError(requestError.message);
} finally {
setIsSubmitting(false);
}
}
return (
<main>
<h1>Завдання</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">Нове завдання</label>
<input
id="task-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Наприклад, написати тести"
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting || !title.trim()}>
{isSubmitting ? "Збереження..." : "Додати"}
</button>
</form>
{error && (
<p role="alert">
{error}
</p>
)}
{isLoading ? (
<p>Завантаження...</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title}{" "}
{task.isPending && <small>— збереження...</small>}
</li>
))}
</ul>
)}
</main>
);
}Тимчасовий ідентифікатор створюється на клієнті:
const temporaryId = `temporary-${crypto.randomUUID()}`;Він не є ідентифікатором із бази даних. Його єдина мета — дозволити знайти саме оптимістично доданий елемент.
Після цього компонент додає тимчасове завдання до локального стану:
setTasks((currentTasks) => [...currentTasks, optimisticTask]);Важливо використовувати функціональну форму setTasks. Вона отримує актуальний стан і безпечніша за використання значення tasks, захопленого замиканням.
Після успішної відповіді тимчасовий елемент замінюється серверним:
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === temporaryId ? payload : task,
),
);Це важливо, оскільки сервер може:
призначити справжній ідентифікатор;
нормалізувати назву;
додати поля createdAt, author або інші метадані;
застосувати бізнес-правила.
Якщо запит завершився помилкою, тимчасовий елемент видаляється:
setTasks((currentTasks) =>
currentTasks.filter((task) => task.id !== temporaryId),
);Одночасно повідомлення про помилку показується користувачу, а введений текст повертається у поле. Це дає змогу повторити спробу без повторного введення даних.
Після мутації можна було б викликати GET і повністю завантажити список заново. Проте локальна оптимістична зміна все одно має відбутися до цього запиту.
Повторне завантаження всього списку не завжди є найкращим рішенням:
це створює додатковий запит;
інтерфейс може знову перейти у стан завантаження;
користувач може побачити мерехтіння списку;
під час паралельних змін можна ненавмисно перезаписати локальні дані.
Краще використовувати відповідь мутації для точкової синхронізації, як у прикладі. Повне оновлення даних доречне, коли мутація впливає на складні похідні дані або сервер повертає значно більше змін, ніж можна надійно відтворити на клієнті.
Оптимістичний інтерфейс не означає, що потрібно приховувати стан запиту. Користувач має розуміти, що зміна ще не підтверджена сервером.
У прикладі для цього використовується поле:
isPending: trueЙого можна застосовувати, щоб:
показати індикатор збереження;
тимчасово зменшити прозорість елемента;
заблокувати повторне редагування;
додати кнопку «Скасувати»;
показати статус «очікує синхронізації».
Окремо зберігається isSubmitting. Він відповідає за стан форми, а не за конкретний елемент списку.
Це розділення корисне, коли застосунок дозволяє виконувати кілька мутацій паралельно. Тоді замість одного булевого значення часто зберігають статус для кожної операції або використовують ідентифікатори мутацій.
Помилка може виникнути на різних етапах:
сервер відхилив дані;
користувач не має потрібних прав;
завершилася сесія;
сталася мережева помилка;
сервер повернув неочікувану відповідь.
Для всіх цих випадків локальний стан не повинен залишатися в стані, який суперечить серверу. Тому відкат потрібно виконувати в catch, а не лише показувати повідомлення:
catch (requestError) {
setTasks((currentTasks) =>
currentTasks.filter((task) => task.id !== temporaryId),
);
setError(requestError.message);
}Якщо мутація не є додаванням, логіка відкату залежить від операції:
для видалення потрібно повернути попередній елемент;
для редагування — відновити попереднє значення;
для зміни статусу — повернути попередній статус;
для переміщення — повернути попередню позицію.
Тому перед оптимістичною зміною варто зберігати мінімальний snapshot попереднього стану.
const response = await fetch("/api/tasks", options);
setTasks((currentTasks) => [...currentTasks, responseTask]);Такий код не є оптимістичним. Інтерфейс оновиться лише після завершення запиту.
Якщо після успішної відповіді просто додати серверний об’єкт до списку, у ньому залишаться два елементи:
тимчасовий;
підтверджений сервером.
Потрібно замінити тимчасовий елемент або видалити його перед додаванням відповіді.
Якщо запит завершився помилкою, але оптимістичний елемент залишився у стані, користувач бачить дані, яких на сервері немає.
Індекс може змінитися після додавання або видалення інших елементів. Для зіставлення оптимістичної операції потрібен стабільний ідентифікатор, наприклад crypto.randomUUID().
Небезпечний варіант:
setTasks([...tasks, optimisticTask]);Якщо між читанням tasks і викликом setTasks відбулася інша зміна, можна втратити оновлення. Для асинхронних мутацій використовуйте:
setTasks((currentTasks) => [...currentTasks, optimisticTask]);Мережева помилка може статися вже після того, як сервер зберіг дані, але до отримання відповіді клієнтом. У такому випадку клієнт виконає rollback, хоча серверна операція фактично завершилася успішно.
Для критичних операцій потрібні додаткові механізми:
ідемпотентний ключ операції;
повторне отримання актуального стану;
узгодження змін із сервером;
коректна обробка повторної спроби.
Оптимістичний інтерфейс покращує сприйняття швидкодії, але не замінює серверну перевірку та синхронізацію.
Оптимістична мутація в Next.js складається з кількох кроків:
створити локальне представлення майбутнього результату;
одразу додати або змінити дані в інтерфейсі;
позначити операцію як таку, що очікує підтвердження;
надіслати запит до сервера;
замінити тимчасові дані відповіддю сервера;
у разі помилки відновити попередній стан і показати повідомлення.
Основне правило: локальний стан може оновлюватися раніше за сервер, але після завершення запиту він має бути узгоджений із серверним результатом.