Суть Читай реальные React/TanStack Query сниппеты над асинхронным бэкендом, предсказывай UX-баг и выбирай фикс с наибольшим рычагом, который senior сделает первым.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги eventual consistency прячутся в обработчике мутации и в пути retry. Прочитай код, предскажи, что увидит пользователь, когда consumer отстаёт или запрос падает, затем выбери фикс, который senior-инженер сделает первым.
Цель
Отработай цикл, который ты запускаешь в каждом ревью async-UX: прочитай мутацию, предскажи расхождение между optimistic-состоянием и серверной правдой и потянись за фиксом, завершающим контракт — снапшот, rollback, reconcile, key — раньше, чем добавишь спиннеры.
Запрос иногда падает (consumer отклоняет запись). Что увидит пользователь и какой единственный фикс с наибольшим рычагом?
Heads-up invalidateQueries рефетчит серверное состояние, но пока рефетч не разрешится, показывается устаревшее optimistic-значение, и если рефетч сам обгонит consumer, он может переподтвердить неверное состояние. Rollback при ошибке должен быть явным.
Heads-up React не знает, что мутация упала и каким было прежнее значение. Без снапшота и восстановления в onError optimistic-запись остаётся.
Heads-up Await на cancelQueries корректен: он останавливает in-flight рефетчи от затирания optimistic-значения. Дефект — отсутствующий снапшот и rollback.
Сниппет 2 — кнопка retry
function PayButton({ orderId, amount }) { const [status, setStatus] = useState('idle'); async function pay() { setStatus('pending'); await fetch('/api/charge', { method: 'POST', body: JSON.stringify({ orderId, amount }), }); setStatus('done'); } // пользователь не видит прогресса, жмёт pay() снова на медленной сети return <button onClick={pay}>Pay</button>;}
Викторина
Completed
На медленной сети пользователь кликает дважды. Что идёт не так и какой верный фикс?
Heads-up Скрытие кнопки — гонка, а не гарантия — refresh или вторая вкладка всё равно перезапустят её. At-most-once требует idempotency key на запросе, а не более быстрого UI.
Heads-up Оба запроса успешны независимо; ловить нечего. Проблема в двух реальных принятых намерениях — только общий ключ сворачивает их в один эффект.
Heads-up Семантика HTTP-метода сама по себе не делает списание идемпотентным — сервер всё равно создаёт новое списание на запрос, пока не дедуплицирует по явному idempotency key.
Сниппет 3 — тост ложного успеха
async function publish(post) { const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(post), }); if (res.status === 202) { toast.success('Published!'); await qc.invalidateQueries({ queryKey: ['posts'] }); }}
Викторина
Completed
POST возвращает 202, а consumer пишет пост ~800мс спустя. Что здесь не так — две вещи?
Heads-up 202 Accepted означает «в очереди», а не «готово». Объявление успеха и рефетч внутри consistency-окна — ровно те два failure mode, о которых предупреждает юнит.
Heads-up toast.success — это fire-and-forget UI; await ничего не меняет. Реальные баги — трактовка 202 как готово и рефетч внутри окна.
Heads-up Никакого 200 не будет — эндпоинт асинхронен по дизайну и возвращает 202. Повтор лишь ставит дублирующую работу в очередь; нужны pending-состояние и reconciliation.
Сниппет 4 — конфликт, который перезаписывает
function onIncomingUpdate(remote) { // у local есть несохранённые правки; remote пришёл от другого клиента setDoc(remote); // last write wins, молча}
Викторина
Completed
Два клиента правят тело одного документа. Этот обработчик запускается на входящем remote-обновлении. В чём senior-возражение и какая форма лучше?
Heads-up Newest-wins на свободном тексте отбрасывает реальную работу и доверяет часам, которые могут разъезжаться. Для тела документа это потеря данных; CRDT детерминированно сливает обе правки.
Heads-up Мемоизация нерелевантна корректности здесь. Дефект — тихая перезапись правок пользователя вместо reconciliation или показа конфликта.
Heads-up Хак с таймингом не разрешает конфликт — он лишь меняет, какая запись потеряна. Нужна реальная стратегия merge (CRDT) или явный выбор конфликта.
Итог
Каждый async-UX баг читается в мутации и в пути retry: optimistic-обновление без пары снапшот-и-onError-rollback оставляет упавшую запись на экране; POST без idempotency key превращает двойной клик в двойное списание; сворачивание 202 в success-тост плюс мгновенный рефетч — это ложный успех в гонке с consumer’ом; а тихий обработчик last-write-wins уничтожает несохранённые правки пользователя. Завершай контракт apply-send-reconcile, ключуй retry, показывай честные pending-состояния и разрешай конфликты осознанно, а не перезаписью.