Оптимістичний інтерфейс користувача без невідповідностей: знімки стану, скасування та перервані запити
Навчіться створювати оптимістичні оновлення, які залишаються коректними: зберігати стан у момент оновлення, оновлювати миттєво, скасовувати оновлення у разі збою, відхиляти застарілі запити та знати, коли не варто використовувати цю схему.
При швидкому з’єднанні процес відправки та отримання відповіді після натискання кнопки «Лайк» залишається непомітним. У ситуації, коли телефон перебуває між слабким LTE та майже непридатним 3G, цей самий процес може тривати п’ять чи десять секунд, і протягом цього часу користувач не знає, чи взагалі було зафіксовано натискання. Оптимістичні підходи до оновлень подолують цю проблему, спочатку змінюючи інтерфейс, а вже потім запитуючи сервер. Цей посібник показує, як реалізувати цю схему у трьох чітких кроках, як захистити її від конкуруючих запитів, а також як визначити, де вона є корисною, а де — перешкодою.
Проблема, прихована за одним натисканням
- Вона викликає кінцеву точку API, яка збільшує лічильник лайків для цього поста.
- Вона додає пост до збереженої колекції у обліковому записі користувача, щоб його можна було знайти пізніше.
Усі три елементи залежать від мережі. Однак у фронтенді достатньо змінити лише два параметри стану: чи заповнене серце та число поруч з ним. Якщо інтерфейс чекає на кожен ефект з боку сервера перед тим, як змінити ці значення, додаток виглядає несправним, навіть коли працює коректно.
Оптимізм як свідома, виправна брехня
Оптимістичне оновлення вирішує цю проблему, змушуючи фронтенд негайно змінити власний стан, ґрунтуючись на припущенні, що нещодавно надісланий запит буде успішним. Як тільки палець відводиться від екрана, серце стає червоним, а показник дорівнює 1 000, хоча на сервер ще нічого не надійшло. Саме ця невелика неправда створює враження миттєвої взаємодії.
Це є прийнятним лише у разі наявності плану дій у разі невдачі припущення. Поїзд може увійти в тунель та втратити з’єднання, або сервер може повернути помилку. Залишення значення на екрані на рівні 1 000, поки база даних все ще вказує 999, створює несинхронізацію: інтерфейс та джерело правди не збігаються. Тоді фронтенд мусить повернутися до справжнього стану — зі зображенням серця та значенням 999 — і зазвичай повідомити користувача за допомогою короткого повідомлення, яке з’являється на мить та зникає, наприклад: «Не вдалося зберегти. Спробуйте ще раз».
Впровадження цього підходу у трьох кроках
Зміна значення змінної та сподівання на успішне виконання запиту — це припущення, а не частина проектування. Надійне оптимістичне оновлення розділяє три завдання: фіксація поточного стану, відображення очікуваного результату та узгодження з відповіддю сервера.
Крок 1: створення знімка стану
Перш ніж щось на екрані зміниться, зафіксуйте значення, які користувач може зараз бачити — чи подобається пост та його поточна кількість лайків. Обробник отримує пост, функцію для зміни стану та функцію відображення повідомлення, а також імпортує API-виклик, який він зрештою здійснить:
// likeButton.js
import { updateLikeOnServer } from "./api.js";
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
// act two goes here
// act three goes here
}
Після цього кроку у вас є єдиний незмінений об’єкт, який точно описує те, що знаходиться на екрані, і готовий до відновлення у разі проблем. Важливо скопіювати ці два поля у новий об’єкт: якщо ви збережете посилання на змінний об’єкт, який згодом зміниться, „знімок“ також зміниться разом із ним.
Крок 2: застосувати очікуваний результат
Далі оновіть інтерфейс безпосередньо, ще до того, як розпочнеться мережевий виклик:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
// act three goes here
}
Тепер екран відображає те, про що просив користувач — червоне серце та більшу кількість лайків, тоді як сервер ще нічого не знає. Одна деталь, на яку варто звернути увагу у вашому власному коді: у цьому прикладі передається абсолютно новий об’єкт, який містить лише поля liked та likeCount. Якщо ваш метод-заставник замінює весь об’єкт повідомлення замість того, щоб об’єднати поля, розподіліть існуючі дані новому об’єкту, щоб такі властивості, як id, не були втрачені.
Крок 3: узгодження з сервером
Нарешті, надішліть справжнє запитання та або збережіть оптимістичний стан, або скасуйте його. Запит розміщується у блоку try; якщо він викликає помилку, обробник відновлює попередній стан та відображає повідомлення:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
try {
await updateLikeOnServer(post.id);
} catch (error) {
setPost(previousState);
showToast("Couldn't save that. Try again.");
}
}
Допоміжний модуль API використовує функцію fetch браузера. Зауважте, що fetch відхиляє запит лише у разі проблем з мережею, а не через статуси помилок HTTP, тому допоміжний модуль перевіряє значення response.ok та явно кидає виняток. Без цієї перевірки відповідь 500 буде вважатися успішною, і процедура скасування ніколи не виконається:
// api.js
export async function updateLikeOnServer(postId) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Like request failed");
}
return response.json();
}
Після третього кроку існує лише два можливі результати. Або запит виконався успішно та оптимістичний стан став істинним, або він зазнав невдачі та інтерфейс повернувся до стану з першого кроку. У обох випадках інтерфейс ніколи не показує того, що бекенд не може підтримувати протягом часу, необхідного для невдачі запиту.
Де оптимістичні оновлення є корисними
Додаткова структура варта уваги лише там, де процедура скасування є простою, а сприймана швидкість має велике значення. Три категорії постійно відповідають цим критеріям:
- Соціальна взаємодія. Лайки, підписки та реакції мають низьку важливість. Якщо одна з багатьох спроб натиснути щодня провалюється, ціною є необхідність натиснути знову.
- Повідомлення. У додатках для чату, таких як WhatsApp або Slack, повідомлення, надіслане при поганому з’єднанні, відразу з’являється у розмові, часто з тьмяним виглядом або з маленькою сірою іконкою годинника. Це оптимістичне оновлення з видимим позначенням очікування: повідомлення з’являється на екрані ще до того, як воно надійде на сервер.
- Панелі типу «перетягни та кинь». Якби картка, яку перетягуєте у колонку «Зроблено» на панелі типу Kanban, наприклад у Jira чи Trello, мусила чекати на підтвердження, це було б схоже на перетягування крізь мокрий пісок. У оптимістичному варіанті вона миттєво опиняється на місці, а якщо запит провалюється — повертається у свою початкову колонку.
Корисним удосконаленням порівняно з класичними повідомленнями є індикатор очікування. Він показує, що дія ще не підтверджена, що дозволяє бути швидким та чесним водночас, а також зменшує несподіванку у разі скасування операції.
Де оптимізм стає небезпечним
Тепер уявіть додаток для банкінгу. Орендар натискає „Надіслати“, щоб переказати орендодавцю п’ять тисяч доларів. За оптимістичним оновленням екран негайно повідомляє, що переказ вдалося та баланс тепер дорівнює нулю, і користувач закриває додаток, вважаючи, що орендна плата сплачена.
Насправді запит зазнає невдачі; чи це через недостатню суму коштів, чи через відхилений переказ — не має значення. Користувач продовжує вірити, що гроші були переказані, поки орендодавець не зателефонує та не запитає про орендну плату. Це принаймні призводить до необхідності створення заявки на підтримку та пошкодження довіри, а таке трапляється через застосування схеми, розробленої для дешевих, відкладних дій, до ситуації, де помилка має реальні наслідки.
Така сама обережність потрібна у кількох пов’язаних категоріях:
- Фінансові транзакції. Показуйте індикатор завантаження. Час очікування свідчить про те, що система справді перевірила операцію перед її підтвердженням.
- Незворотні видалення. Коли хтось видаляє свій акаунт, екран не повинен очищуватися, поки сервер не підтвердить, що дані видалені.
Вирішальним є не те, наскільки значущою здається дія, а те, якими будуть наслідки помилки. Краще змусити користувача почекати ще три секунди на завантаження, ніж повідомляти йому про переказ грошей, коли цього не відбулося.
Коли два дотики змагаються між собою
Існує ще один спосіб збою, який виникає лише тоді, коли користувач робить дію знову до завершення першого запиту. Наприклад, користувач лайкає пост, піднімаючи його з рейтингу 999 до 1 000, а потім негайно скасовує лайк, повертаючи рейтинг на 999. Якщо для лайку потрібно п’ять секунд, а для скасування лайку — лише дві, то скасування лайка може відбутися раніше.
Сервер потім обробляє дію „не сподобалося“ для повідомлення, яке він ще не вважає сподобаним, тож нічого не відбувається, а через кілька секунд обробляє дію „сподобалося“. У результаті на сервері статус сподобання залишається, тоді як на екрані відображається протилежне до того, що користувач робив останнім. Коли кінцевий стан визначається за порядком надходження запитів, а не за послідовністю дій користувача, виникає ситуація змагання.
Скасування застарілих запитів за допомогою AbortController
Звичайним рішенням є скасування: коли починається новіший запит до того ж ресурсу, потрібно попросити браузер відмовитися від старшого запиту. Вбудований AbortController може скасувати запит fetch, який вже у процесі виконання. У прикладі зберігається посилання на активний контролер, він скасовується перед початком нового запиту, а вибір між POST та DELETE здійснюється відповідно до бажаного статусу:
// likeButton.js
let activeRequest = null;
async function toggleLike(postId, shouldLike) {
if (activeRequest) {
activeRequest.abort();
}
const controller = new AbortController();
activeRequest = controller;
await fetch(`/api/posts/${postId}/like`, {
method: shouldLike ? "POST" : "DELETE",
signal: controller.signal,
});
}
Кожен новий натиск скасовує попередній запит, тому має виконатися лише остання дія. Є два моменти, які варто знати перед тим, як покладатися виключно на цей підхід:
- Скасування запиту заважає браузеру чекати на відповідь; це не гарантує, що сервер ніколи не отримував чи не обробляв запит. Для сильніших гарантій потрібно спроєктувати кінцеву точку так, щоб вона відображала бажаний кінцевий стан (сподобалося чи ні), щоб повторні або переупорядковані запити призводили до однакового результату, або додати номер послідовності, який сервер може використати для ігнорування застарілих даних.
- Скасований запит
fetchповертає помилкуAbortError. Якщо поєднувати це з процедурою скасування з третього кроку, потрібно перевіряти цю помилку та пропускати скасування, інакше скасування застарілого запиту може помилково скасувати новіший оптимістичний стан.
У додатках на React бібліотеки даних відповідають за більшу частину цих функцій. TanStack Query, який керує кешуванням стану сервера та життєвим циклом запитів, та Redux Toolkit — офіційний набір інструментів для розробки логіки Redux — обидва забезпечують вбудовану підтримку оптимістичних оновлень та скасування, тож вам рідко доводиться вручну писати код з AbortController. Ознайомтесь із їхньою документацією, щоб точно зрозуміти, що скасовується в кожному випадку; все одно корисно знати механізм, який стоїть за цим. Щоб детальніше дізнатися, як можуть виникнути проблеми під час скасування за допомогою вбудованих хуків React, перегляньте п’ять способів збою оптимістичних скасувань.
Ключові висновки
- Міжсерверний обмін даними може бути настільки повільним, що працездатний додаток може здаватися несправним, тому оптимістичні оновлення спочатку показують очікуваний результат, а потім підтверджують його.
fetch не відхиляє запити через помилки HTTP, тому потрібно кидати виняток при !response.ok, а також повідомляти користувача за допомогою попередження під час скасування дії.AbortError у логіці скасування та використання кінцевих точок, які встановлюють остаточний стан, а не змінюють його.Швидкість — це функція, яку мережа не надасть вам безкоштовно. Оптимістичний інтерфейс усвідомлює, що не може контролювати з’єднання, проте все одно впевнено відповідає користувачеві та залишається готовим скасувати свою відповідь у момент, коли це виявиться неправильним.